Skip to content

feat(session): 新增 replyDelivery=transcript 转写回复模式,并桥接 Claude Code statusline 配额 - #1310

Open
lRoccoon wants to merge 5 commits into
deepcoldy:masterfrom
lRoccoon:pr/transcript-reply-mode
Open

feat(session): 新增 replyDelivery=transcript 转写回复模式,并桥接 Claude Code statusline 配额#1310
lRoccoon wants to merge 5 commits into
deepcoldy:masterfrom
lRoccoon:pr/transcript-reply-mode

Conversation

@lRoccoon

@lRoccoon lRoccoon commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

先说清楚:这个 PR 里 claude-code 的缺省行为会变(第 3 个提交),是个产品口径的决定,不是纯技术改进。如果维护者不认同这个缺省,前两个提交仍可独立成立——把第三个提交去掉即可,replyDelivery 就退回「所有 CLI 缺省 send、显式开启才生效」。CoT 气泡那组无关改动已拆成单独的 PR。

动机

当前所有 CLI 的最终回复都要模型自己执行 botmux send。为此每轮 prompt 要注入 <botmux_reminder> 提醒它别沉默,系统提示里要教它 heredoc 写法、--mention 三选一硬门、附件参数,<identity> 里还要写「协作必须 botmux send --mention」。对私聊、或只有本人和一个 bot 的 1v1 群来说,这些多 bot 归属规则和发送规约全是噪声,模型每轮都要读一遍;用户在输入框里看到的也不是自己发的话,而是一层 XML 信封。

worker.ts 里其实早就有一条完整的转写兜底:模型这一轮没调 botmux send 时,daemon 会从 CLI 的 transcript 取本轮最后的 assistant 文本,发成最终回复卡(抑制规则见 bridge-fallback-gate.ts,投递带重试与 uuid 幂等)。codex-app 适配器更是已经在系统提示里明说「最终 assistant 消息由 botmux 自动转发,普通回复不要调用 botmux send」。

这个 PR 把那条兜底升为一等通道,并顺带把 Claude Code 的 statusline 数据接进卡片用量段。

三个提交

1. replyDelivery: 'send' | 'transcript'(新配置,缺省 send,行为不变)

transcript 模式下:

  • 系统提示与 shell hints 改口为「最终 assistant message 由 botmux 自动转发回飞书」,botmux send 只保留给中途推送、附件、跨 bot @;BOTMUX_NOTHING_TO_SEND 哨兵语义不变(它仍是转写兜底的抑制规则)。
  • 续轮不再注入 <botmux_reminder>
  • solo 会话去壳:p2p,或普通群里 user_count ≤ 1 && bot_count ≤ 1 且本轮发言者就是 owner。这时 <user_message> 壳、<sender/><attachments><mentions> 全部省掉,改用 buildBridgeInputContent 的裸文本 + [附件] / [@提及] 行(与 /adopt 桥接会话同一形态,那条路径已在线上跑了很久)。判定 fail-closed:话题群、多人群、成员数取不到、非 owner 发言一律不算 solo。复用 getChatMode / getGroupStats 的现有缓存,send 模式下不产生任何额外 API 调用。
  • 最终回复卡投递成功后,把该轮的流式卡头部标成「已完成」(DaemonSession.completedIdleTurnIdFrozenCard.idleLabel 兼容旧盘的 silentIdle)。
  • 只允许对有转写采集的 CLI 开启(claude-codestructured-bridge-clis 白名单)。bot-config-store 在写盘前拒绝不支持的 CLI,effectiveReplyDelivery 运行时再兜一层,/botconfig set cli 换到不支持的 CLI 后自动回落 send 并 warn 一次。

turn 指纹不受影响:bridgeMarkPendingTurn 用的是 daemon 发来的原文,指纹是归一化后前 30 字符加后缀证明;裸文本反而比现状更有区分度(现状每轮都以 <botmux_reminder> 开头,前 30 字符完全相同,只能靠 FIFO 顺序区分)。

2. 桥接 Claude Code statusline,卡片显示 ctx 23% · 5h 18% · 7d 5%

claude-code 的 transcript 里没有上下文窗口字段,所以卡片只能显示上下文绝对值(上下文 674.3K),既没有百分比,也没有任何账号配额信息——contextOverCompactThreshold 的「建议压缩」提示对 Claude Code 从来没触发过。

而 Claude Code 的 statusLine 机制会把 context_window.used_percentagerate_limits.five_hour / seven_day 喂给配置的命令。本提交:

  • 新增 botmux statusline 子命令:读 stdin JSON,原子写 <DATA_DIR>/statusline/<sessionId>/latest.json(用目录而非单文件,因为沙箱对单文件只授 readWrite,tmp+rename 需要父目录可写)。用户自己的 statusline 不会被吃掉:spawn 前按 Claude 的优先级解析出被遮蔽的 statusLine.command 写进 BOTMUX_STATUSLINE_CHAIN,子命令把原始 stdin 字节转发给它并透传 stdout 与退出码,10s 看门狗按进程组收尾。任何失败都不影响 Claude 的状态栏。
  • claude-code 的进程级 --settings 注入 statusLinerefreshInterval: 60)。由此 claude-code 会恒传 --settings(此前 disableCliBypass 时不传),test/claude-settings-hook.test.tstest/cli-adapters.test.ts 的相应断言已更新为「传,但只有 statusLine 键」。
  • getDaemonSessionUsageSnapshot 对 claude-code 合并 quota,回复卡页脚、流式卡用量行、GET /api/sessions/:id/usage 三个读取点一处覆盖。有数据时上下文段渲染纯百分比 ctx 23%,追加 5h N% / 7d N%;没有数据时输出逐字节不变。不渲染 resets_at、不画进度条。

沙箱侧:fs-policy 两个分支各授 statusline/<sid> 目录 readWrite,spawn 前预建目录(bwrap 不能 bind 不存在的源),BOTMUX_STATUSLINE_CHAINBOTMUX_INJECTED_ENV_KEYSSESSION_TURN_MARKER_ENV_KEYSwrapperCli 剥掉 --settings 时(如 aiden)自然没有数据,卡片省略该段。

3. claude-code 缺省切到 transcript,提示里彻底不提 botmux send

缺省值改为按 CLI 决定:claude-code 缺省 transcript,其它 CLI 缺省 sendbots.json 显式写 "send" 才让 claude-code 退回旧行为,unset 回各 CLI 缺省。transcript 分支的提示只保留自动转发说明、botmux history / botmux bots list、哨兵、workflow、防注入与白板;heredoc、--mention 三选一、附件参数、<identity>mention_must 全部去掉。附件与跨 bot @ 的用法仍在 --plugin-dir 注入的 botmux-send skill 里,模型需要时能自己发现。

为什么把缺省也翻了:留着 botmux send 的教学文案而实际通道已经不是它,模型会两头都做——既在最终消息里作答,又调一次 send,用户看到重复内容。要么全套保留,要么全套去掉,中间态最差。是否接受这个缺省由维护者决定,去掉本提交不影响前两个。

影响面

  • 公共层:session-manager(信封)、shared-hints(系统提示)、worker-pool(init IPC、用量快照、最终卡投递)、card-builder / card-handler(idle 标签)、md-card(用量段)、cli.ts(新子命令)。
  • 所有新分支都以 effectiveReplyDelivery(...) === 'transcript' 为前置。显式 send 与非 claude-code 的 CLI 输出逐字节不变(现有 prompt 测试里的 toBe 等式守卫已扩展到「缺省 vs 显式 send」两种写法)。
  • 不受影响:/adopt 桥接(本就无信封)、v3 workflow(V3_MARKER 时强制 send)、oncall 多人群与话题群(非 solo)、HTTP / apiOnly 无传输会话(noTransport 优先级更高)、codex-app(天然转写模式,不在白名单)。
  • 已知代价,已写进文档:solo 会话的裸文本没有 <user_message> / <sender> 结构,/adopt 的自产会话识别少一条指纹(与 senderTag: false 同类)。

验证

bun install --frozen-lockfile        # 通过
bun run build                        # 通过
node_modules/.bin/tsc --noEmit       # 通过
vitest run --project unit \
  test/reply-delivery.test.ts test/prompt-builder.test.ts test/cli-adapters.test.ts \
  test/prompt-hook-injection.test.ts test/bot-config-store.test.ts test/dashboard-*.test.ts \
  test/statusline-snapshot.test.ts test/statusline-cli.test.ts test/card-usage-normalize.test.ts \
  test/worker-pool-statusline-quota.test.ts test/md-card.test.ts test/card-builder.test.ts \
  test/bridge-final-output-retry.test.ts test/recall-frozen-cards.test.ts \
  test/silent-turn-receipt.test.ts test/claude-settings-hook.test.ts test/fs-policy.test.ts \
  test/child-env.test.ts test/tmux-backend-env.test.ts test/hook-command.test.ts \
  test/bridge-turn-queue.test.ts
  → Test Files 23 passed | Tests 1787 passed | 1 skipped

新增用例覆盖:computeSoloSession 全分支表驱动、CLI 白名单与 fail-closed 回落、transcript 续轮无 reminder、solo 裸文本信封(附件成 [附件] 行、自 @ 被剥)、非 solo 保留壳与 sender、noTransport 优先、缺省与显式 send 的字节等式、裸文本 turn 指纹命中、idle + completed 标签与冻结卡回读、__testOnly_deliverFinalOutput 下仅 transcript + lineage 匹配才置位;statusline 子命令的落盘 / 无 sessionId / chain 字节透传 / 退出码透传 / 看门狗 / 非 JSON / workflow 白名单,快照的 mtime 缓存与陈旧丢弃,页脚与流式卡两个变体的渲染字符串,以及「无 quota 时与现状逐字节相同」。

在一台接飞书的 daemon 上跑过 live:私聊里模型不调 botmux send 也收到最终回复卡,转写里本轮输入是裸文本,该轮流式卡标「已完成」,页脚出现 ctx N% · 5h N% · 7d N%tmux attach 后本机原有的 statusline 文案未被替换;多人群回归确认 sender 与壳仍在。卡片是飞书客户端渲染,未附截图。

🤖 Generated with Claude Code

https://claude.ai/code/session_01YMAYq1f2eZstsgAu2HKFmN

@lRoccoon
lRoccoon requested a review from deepcoldy as a code owner September 7, 2026 15:55
@deepcoldy

Copy link
Copy Markdown
Owner

你好!评审群已自动创建:pr 1310 转写回复模式并桥接statusline配额

但你暂未拉入群中——自动拉群名单里你的 GitHub 账号尚未关联飞书信息。请自行把 GitHub 账号和飞书信息补进名单文档:https://bytedance.larkoffice.com/wiki/WJ1nwWbtxi89erkNGNbcgkt9nUe ,补好后后续复审会自动拉你进群。

这是自动流程,如有疑问请联系维护者。谢谢!

@deepcoldy

deepcoldy commented Sep 8, 2026

Copy link
Copy Markdown
Owner

自动评审意见(Claude 首审 + 复审,五轮收敛后的终版)

本条已合并此前的多条追加评论,只看这一条即可

先说结论:工程质量很高——三个提交切分干净、fail-closed 判定齐备、注释写的是「为什么」而不只是「做了什么」。有 1 条建议合入前确认(F1),其余为非阻断。

验证基线:在最新 origin/master 上 rebase 后验证(当时为 ce4648b59),rebase 后树与 merge-tree 预测树逐字节相同。bun run build 通过;PR 触及的 25 个测试文件 1826 passedsend 模式字节等式实测复核(buildBotmuxShellHints / buildBotmuxSystemPromptText 的「缺省 vs 显式 send」输出完全相同);对 6 处承重逻辑做变异探针(fail-closed 回落、solo 的话题群/owner 两道门、快照陈旧丢弃、滚动窗口丢弃、transcript 不注入 reminder),6 枪全部转红


F1(建议合入前确认)transcript 下,一次中途 botmux send 会静默吞掉本轮真答案

shouldSuppressBridgeEmitsrc/services/bridge-fallback-gate.ts本 PR 未改动)的规则是:本轮窗口内出现过 send marker 时,除非转写 final 明显更长,否则抑制 fallback。

这条规则在 send 模式下是正确的——那时 final 只是兜底,答案已由 botmux send 发出,抑制掉正是去重。但 transcript 下 final 就是投递通道,同一条规则的后果因此反转:模型中途发过一条短消息,这一轮的真正答案就被吞掉,用户什么也收不到。

三条会吞答案的路径(均已实测,rebase 后树上)

① 长度比例闸(2x + 120 字符)

中途 send 7 字   =>  final 需 ≥ 127 字才送达
中途 send 50 字  =>  final 需 ≥ 170 字
中途 send 120 字 =>  final 需 ≥ 240 字

② 「已 send + final 尾部 sentinel」硬抑制——不看长度

prose + 尾部 sentinel + 1 条 marker      -> 抑制
prose 5000 字 + sentinel + 1 条 marker   -> 仍抑制   ← 与长度无关
prose + 尾部 sentinel + 0 marker         -> 送达(对照组)

transcript 提示词仍教 sentinel(ai.shell.when_to_send_transcript),所以「发过附件说明 → 真答案写进 final → 习惯性补 sentinel」必吞。

③ 无 contentLength 的 marker 走 back-compat 分支直接抑制——同样不看长度

纯附件发送(无正文)  legacy-shaped=true   5000 字 final -> 抑制
纯空白正文          legacy-shaped=true   5000 字 final -> 抑制
正常正文            legacy-shaped=false  5000 字 final -> 送达(对照组)
旧 + 结构化 混合     5000 字 final -> 抑制   ← 一条旧 marker 污染整个集合

这一条不是「升级前的存量文件」,是现役代码路径--voice 路径(cli.ts:9030 附近)的 marker 手工拼装,结构上永远没有 contentLength;主路径的 buildBridgeSendMarkerContent 在内容归一化后为空时返回 undefined''/纯空白/纯换行实测都是),Object.assign 遇 undefined 是 no-op。即 botmux send --images x.png(不带正文)--voice 语音回复 这两个日常形状,在当前代码 + claude-code 缺省 transcript 下必吞答案。

为什么这条路径可达

  1. botmux-send skill 仍在 --plugin-dir 注入并出现在 skill catalog(实测 transcript 系统提示里那行还在);shared-hints.ts 注释明写「附件、跨 bot @ 等场景模型可经内置 botmux-send skill 自行发现」——冲突路径是设计意图,不是意外
  2. 该 skill 正文没有 transcript 变体,仍写「想让用户看到的内容必须通过 botmux send 发送」;
  3. worker.ts 只用 cfg.replyDelivery 选提示词措辞(14302 行),抑制闸完全不知道 replyDelivery 的存在
  4. 第 3 个提交把 claude-code 缺省切到 transcript,等于所有 claude-code 会话默认走这条路。

建议修法(三条,都在同一函数内,transcript 下生效)

  1. 长度比例闸 → 改为「仅当 final 与某条 send 内容指纹完全相同时才抑制」;
  2. 「已 send + 尾部 sentinel」硬抑制 → prose 是答案,应当送达;
  3. contentLength 的 marker → 不抑制(重复一条,好过静默吞答案)。

闸改成 mode-aware 后,结构化 CLI(codex/traex 等,缺省 send)与 claude-code(emitReadyTurns)所有路径一并覆盖,不必逐点修。另建议给 botmux-send skill 出 transcript 变体(「附件/举手用 send,结论写 final」),但这是缓解,替代不了上面三处。

测试要求:回归测试请走 recordBridgeSendMarker 的真实构造(或抽等价 builder),覆盖三种 marker 形状(--images 无正文 / --voice / 正常正文)。手搓 { sentAtMs, contentLength } 永远是结构化形状,会把第 3 条测成假阴性。再加两条断言钉住:prose+sentinel ⟹ prose 送达;指纹完全相同 ⟹ 仍抑制(防重复不能被一起改没)。

归因说明

这条闸是主干既有代码、本 PR 一个字没改,在 send 缺省下也不是缺陷。是「final 升为主通道」这个语义变化让同一段代码的后果反转了,且中途 send 在 transcript 下被设计为合法(附件/举手)——所以倾向认为该在本 PR 内一并处理。另:shouldSuppressBridgeEmit 的 6 个 return 点已逐条按 transcript 语义走查,反转只有上述这些,adopt / 裸 sentinel / isLocal / markTimeMs=undefined 四条方向都对。

与缺省翻转的联动:若去掉第 3 个提交,F1 影响面收窄为「显式 opt-in transcript 的 bot」,紧迫度不同;但闸的语义反转在两种形态下都存在,三条修法不变


非阻断

  • N1 BOTMUX_STATUSLINE_CHAIN 在非 claude-code 分支显式 delete,避免 rcfile/tmux 残留值让别的项目的 statusline 在本会话执行,并进了 SESSION_TURN_MARKER_ENV_KEYS——这点比很多同类改动都稳。
  • N2 statusline chain 的 10s 看门狗按进程组收尾(detached + kill(-pid)),考虑到了用户脚本内部再 spawn jq/git 的孤儿场景。
  • N3 resolveSoloSessionForTurn 在 pending-worker 同步屏障分支沿用旧值不重算(不能 await 网络),逻辑正确,solo 可能滞后一轮属可接受取舍,注释已写明。
  • N4 第 3 个提交是产品口径决定,PR 描述交代清楚并给了退路,缺省是否翻转请维护者定。

⚠️ 基线已过期,需要 rebase

评审期间 master 前移,#1266(按触发人身份调用 CLI)已合入,与本 PR 6 个文件重叠、共 8 处冲突。我本地试解过(未推送、未碰你的远端):7 处是纯叠加(双方各自往同一签名 / options / import 区加参数,如 buildBotmuxSystemPromptText 一边加 triggerUserAuth、一边加 replyDelivery/solo),并存即可。

只有 1 处需要留意——src/core/session-manager.ts 的第 2 个冲突块,HEAD 侧是 triggerUserAuthEnabledForPrompt、PR 侧是 replyDeliveryFor两个函数体的收尾 } 在冲突标记之外被双方共用>>>>>>> 的下一行就是那个孤立的 })。按常规「两边都要」解会让第一个函数没闭合,bun run buildTS1128。正解是保留两段函数并自己补一个 }。解完 rebase 请先跑一次 build 再 push。

好消息:以上评审结论不受基线漂移影响——F1 涉及的 bridge-fallback-gate.ts 与写 marker 的 cli.ts 在新 master 上与评审时逐字节相同#1266 没碰它们。在解完冲突的合并树上复验过:bun run build 绿、12 个测试文件 1089 passed(同时覆盖本 PR 与 #1266 的用例),F1 探针复现结果与旧基线逐字相同。


以上为自动评审意见,可能有误判,最终以维护者审阅为准。F1 的每条判断都附了可复现的实测数据,如认为触发条件有偏差欢迎直接指出。感谢作者——这个 PR 的注释质量与验证覆盖都远高于平均水平,F1 是「兜底升主通道」的语义连带效应,不是实现疏忽。

@lRoccoon

lRoccoon commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

感谢这份评审,F1 的三条路径都复现了,已在本 PR 内一并处理;rebase 也做了。

F1 修复

抑制闸改成 mode-aware:shouldSuppressBridgeEmit 新增 replyDelivery 参数,缺省 'send' —— 历史行为逐字节不变。transcript 下三条路径翻转:

  1. 长度比例闸 → 换成「只有内容等同才算重复」。这里跟评审的措辞有一点出入,说明一下:marker 只存 contentLength(fingerprint 归一化长度)和 bounded previewText没有完整正文,所以「内容指纹完全相同」没法直接比。落地成 长度完全相等 + 预览是 final 的前缀(预览截断时的尾部 会先去掉再比)。长度不同一律送达,比原来的 2x+120 严格得多,同时保住了 dedup。
  2. 「已 send + 尾部 sentinel」硬抑制 → transcript 下不再适用,剥掉 sentinel 后交给上面的重复判定。
  3. contentLength 的 marker → 不再抑制。确认了这是现役路径而非存量文件:buildBridgeSendMarkerContent 对空内容返回 undefined,所以 --images 不带正文与 --voice 都落在这个形状。

裸 sentinel / adopt / isLocal / markTimeMs=undefined 四条按评审的走查结论保持不变。shouldEmitEmptyCompletedBridgeFallbackshouldEmitFailedBridgeFallback 一并透传该参数。

测试

照要求用 buildBridgeSendMarkerContent 真实构造 marker,没有手搓 { sentAtMs, contentLength } —— 那样第 3 条会测成假阴性。5 个新用例覆盖:三种 marker 形状(--images 无正文 / 无正文+结构化混合 / 正常正文)、prose+sentinel 送达、内容等同仍抑制。最后一条还加了对照:长度相同但正文不同必须送达,防止 dedup 被一起改没。

多数用例是 send/transcript 双向断言(同一输入两种模式期望相反),实现若失效必然转红。

rebase

已 rebase 到最新 master(7d83eabd)。session-manager.ts 那个陷阱确认存在并按提示处理了——两个函数体的收尾 } 在冲突标记之外被双方共用,补了一个 };如果按常规两边都要来解,tsc 会报 TS1128。另外 daemon.ts 两处不是纯叠加:master 已把那段重构进 admitFollowerBehindOpeningbuild 回调,本 PR 的 resolveSoloSessionForTurn / solo / selfMention 三处增量搬进了该回调内。

rebase 后 bun run build 绿,tsc --noEmit rc=0,相关 5 个测试文件 233 passed

@lRoccoon
lRoccoon force-pushed the pr/transcript-reply-mode branch from 4067757 to f89cb56 Compare September 8, 2026 16:55
@deepcoldy

deepcoldy commented Sep 9, 2026

Copy link
Copy Markdown
Owner

复审(f89cb564d F1 修复):F1 已正确修复,补一条窄影响面的新点

自动评审初步意见,最终以维护者审阅为准。

基线:本分支当前基于 #13237d83eabdc),落后最新 master 两个 commit(#1331#1307)。我本地 rebase 到 df89a7782 验证(未推送、未改你分支):只有 src/core/session-manager.ts 1 处机械冲突——#1307 把 follow-up 处的 renderRoleContextBlock 改名成了 renderApplicationRoleBlock。解法:保留本 PR 在该处新增的 noTransport/transcript/bare 三个常量,函数名用新的 renderApplicationRoleBlock(其余 3 个调用点 git 已自动跟着改名)。解完 bun run build + 465 测试全绿。

F1 修复正确,三条路径都堵住了

shouldSuppressBridgeEmit 改成 mode-aware(replyDelivery 缺省 'send' 即历史行为):

  • 长度比例闸 → 仅「长度相等 + 预览前缀匹配」才算重复,长度不同一律送达;
  • prose + 尾部 sentinel 硬抑制 → transcript 下豁免,剥掉 sentinel 交重复判定;
  • contentLength 的 marker → 不再抑制。

worker 把 replyDeliveryMode() 透到了全部 6 个闸调用点,init 也确实冻结了该字段。测试严格按要求用 buildBridgeSendMarkerContent 真实构造(三种 marker 形状 + 「长度相同正文不同也送达」的反向钉),我另外做了 4 枪变异(长度判据反转 / sentinel 豁免删除 / 预览前缀跳过 / legacy marker 恢复抑制)全部转红,send 模式 74 个既有用例保持全绿 = 历史行为不变。

新发现 F2(窄影响面,建议顺手修;不影响默认用户)

markerSetDuplicatesFinal 里空 final 的分支:

const finalNormalized = normaliseForFingerprint(finalText ?? '');
if (!finalNormalized) return true;   // 注释说「keep the old behaviour」,但并非如此

emitReadyCodexTurns 的外层闸(worker.ts:7739)对合成的失败卡 / 空回合诊断(shouldEmitFailedBridgeFallback / shouldEmitEmptyCompletedBridgeFallback)重跑抑制闸时,传入的 gateInput.finalText空的(可见文案是 content 现合成的,不在 finalText 里)。于是 transcript 下即便模型一条消息都没发return true 也会把失败卡 / 安全网诊断压掉。而 send 模式对「空 final + 零 marker」是送达markerSetCoversFinalmarkers.length === 0 → false)——所以这行并没有保持旧行为。

实测组合路径:failed 回合无 send、transcript → 失败卡被抑制;改成下面一行后恢复:

if (!finalNormalized) return markers.length > 0;

改后 74 个闸测试全绿,空回合诊断与失败卡在无 send 时送达、有中途 send 时仍抑制(与 send 语义一致),正常答案的去重不受影响。replyDelivery 也应透给 structuredFallbackKind——这不只是口径统一,在一个长度窗口里能保住失败原因:失败回合带一段 partial final、且中途发过一条较短 send 时,若 partial 比 send 长但没长过 send 的 2x+120 阈值(实测 partial 60–150 字 / send 50–100 字均在此窗口),send 缺省分类会跳过 failed 判定、落到 finalText 非空 → 'final',于是只送 partial、失败原因被吞;透传 transcript 后 shouldEmitFailedBridgeFallback 不被抑制、正确判 'failed'(失败卡带 partial + 原因)。partial 远超阈值时两种语义都判 failed,所以只影响这个中间窗口。(此窗口在 send 模式下本就存在,是既有行为;透参只是让 transcript 走它本该走的语义。)

影响面:默认 transcript 的 claude-code 不受影响——它走 emitReadyTurns,空文本回合直接 continue、不合成这类诊断卡。只有显式配置 replyDelivery=transcript 的结构化 CLI(codex/traex/coco 等,白名单支持)才会走到。所以是 opt-in 配置下才触发的窄问题,但既然白名单放行这些 CLI 用 transcript,建议一并修对。

辛苦!修复整体质量很高。

lRoccoon and others added 5 commits September 9, 2026 17:00
per-bot 配置 `replyDelivery: 'send' | 'transcript'`(默认 send,行为与之前逐字节相同)。
transcript 模式下:

- 系统提示与 shell hints 改口为「最终 assistant message 由 botmux 自动转发回飞书」,
  `botmux send` 只用于中途推送 / 附件 / 跨 bot @;哨兵 BOTMUX_NOTHING_TO_SEND 语义保留。
- 不再逐轮注入 `<botmux_reminder>`(hook 模式下 sidecar 随之为空,自动回退 inline)。
- solo 会话(p2p,或仅 owner + 本 bot 的 1v1 普通群,fail-closed)用裸文本 + `[附件]` 行
  代替 `<user_message>` 壳、`<sender/>`、`<attachments>`、`<mentions>`;identity 块去掉
  多 bot routing_rules。判定复用 getChatMode / getGroupStats 缓存,send 模式零额外 API。
- 转写 fallback(模型不 send 时自动转发最终回复)升为主通道,抑制规则不变;最终回复卡
  投递成功后该轮流式卡头部标「已完成」(DaemonSession.completedIdleTurnId,FrozenCard.idleLabel
  兼容旧盘 silentIdle)。
- 只允许对有转写采集的 CLI 开启(claude-code + structured-bridge 白名单),bot-config-store
  拒绝不支持的 CLI,运行时二次 fail-closed;/botconfig set、dashboard 开关、bots-json 文档。

影响面:改了 session-manager / shared-hints / worker-pool / card-builder 公共层,所有新分支
都以 replyDelivery === 'transcript' 为前置;adopt、v3 workflow(V3 marker 强制 send)、
oncall 多人群、话题群、HTTP no-transport 会话路径不变。

验证:tsc --noEmit 通过;vitest 定向 16 个文件 1176 用例通过(prompt-builder /
prompt-hook-injection / cli-adapters / bridge-turn-queue / reply-delivery / bot-config-store /
dashboard-ipc / card-builder / recall-frozen-cards / bridge-final-output-retry 等)。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMAYq1f2eZstsgAu2HKFmN
claude-code 的转写没有上下文窗口字段,卡片只能显示上下文绝对值,且完全没有账号配额
信息。Claude Code 的 statusLine 机制会把 context_window.used_percentage 与
rate_limits.five_hour / seven_day(used_percentage、resets_at)喂给配置的命令,本次把它
接进卡片:

- 新子命令 `botmux statusline`:读 stdin JSON → 原子写
  <DATA_DIR>/statusline/<sessionId>/latest.json(目录粒度:沙盒对单文件只授 readWrite,
  tmp+rename 需要目录可写);BOTMUX_STATUSLINE_CHAIN 非空时把原始 stdin 字节转发给用户
  自己的 statusline 命令并透传 stdout / 退出码(10s 看门狗按进程组收尾),用户的终端
  状态栏不被遮蔽。任何失败都不影响 Claude;BOTMUX_WORKFLOW 白名单放行。
- claude-code buildArgs 的进程级 --settings 恒注入 statusLine(refreshInterval 60s,
  冷启动实测 node 0.34s / 二进制 0.24s);spawn 前按 Claude 优先级解析被遮蔽的
  statusLine.command 写入 BOTMUX_STATUSLINE_CHAIN(child-env 白名单、tmux pane 转发);
  沙盒预创建目录并在 fs-policy 两分支授权。
- services/statusline-snapshot.ts:parse / 原子写 / mtime 缓存读 / 10 min 陈旧丢弃 /
  已滚动窗口丢百分比;worker-pool getDaemonSessionUsageSnapshot 对 claude-code 合并
  quota,回复卡页脚、流式卡用量行、IPC /usage 三个读取点一处覆盖;normalizeCardUsageSnapshot
  迁到 cli/card-usage-normalize.ts 并放行 quota。
- md-card:有 statusline 数据时上下文段渲染纯百分比 `ctx 23%`(不带绝对值、不画进度条、
  不渲染 resets_at),追加 `5h N%`、`7d N%`;「建议压缩」阈值对 claude-code 首次生效。
  无数据时输出逐字节不变。

影响面:非 claude-code CLI 的 quota 恒为空,走原渲染分支;wrapperCli=aiden 剥掉 --settings
时无数据、卡片省略;disableCliBypass 的会话现在也会带一个只含 statusLine 的 --settings。

验证:tsc --noEmit 通过;vitest 定向 statusline-cli / statusline-snapshot / claude-settings-hook /
cli-adapters / md-card / worker-pool-statusline-quota / card-usage-normalize / hook-command /
fs-policy / child-env / tmux-backend-env / dashboard-ipc 共 1078 用例通过。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMAYq1f2eZstsgAu2HKFmN
…mux send

用户反馈:最终回复已由转写通道自动转发,claude-code 在任何情况下都不该再被教
「用 botmux send 回复」。

- 缺省值按 CLI:`defaultReplyDeliveryFor` 让 claude-code 缺省 transcript,其它 CLI 缺省
  send;bots.json 显式 `"send"` / `"transcript"` 才覆盖,`unset` 回 CLI 缺省。
  bot-config-store 不再把 send 归一为删 key(那是 claude-code 退回旧行为的唯一方式),
  `/config get` 缺省显示随 CLI;dashboard GET 返回生效值与 `replyDeliveryDefault`。
- transcript 模式的系统提示只保留:自动转发说明、`botmux history` / `botmux bots`
  辅助命令、`BOTMUX_NOTHING_TO_SEND` 哨兵(仍是转写 fallback 的抑制规则)、workflow、
  防注入、白板;去掉 send 用法、heredoc、@ 三选一、附件参数;identity 在非 solo 下保留
  三条归属规则、去掉「协作必须 botmux send --mention」;非注入式 CLI 的 short_routing
  同样不再注入。附件 / 跨 bot @ 的能力仍可经 `--plugin-dir` 的 botmux-send skill 按需发现。
- 删除不再引用的 `ai.routing.usage_send_transcript`、`ai.shell.how_to_send_transcript`;
  改写 `intro_transcript` / `when_to_send_transcript` 为不含 send 的措辞(zh/en)。
- 文档:bots-json 的 replyDelivery 行与小节按新缺省改写;顺手给 senderTag 说明段补回
  丢失的小标题(e0effcda 插入 replyDelivery 小节时把它挤成了上一节的尾巴)。

影响面:显式 `replyDelivery: 'send'` 与非 claude-code CLI 的输出逐字节不变;claude-code
未配置时从本次起进入 transcript(系统提示部分需会话冷启动生效)。

验证:tsc --noEmit 通过;vitest reply-delivery / prompt-builder / cli-adapters /
prompt-hook-injection / bot-config-store / dashboard-* / bridge-final-output-retry /
cot-message / md-card / session-adopt / skill-injection-mode 共 12 文件 1003 用例通过。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMAYq1f2eZstsgAu2HKFmN
评审 F1:shouldSuppressBridgeEmit 的抑制规则在 send 与 transcript 两种语义下
后果相反。send 模式里 final 只是兜底,「本轮已 send 过」就抑制它,正是去重;
transcript 模式里 final **就是**投递通道,同一条规则会让模型中途发过一条短
消息之后,整轮的真答案静默消失——而中途 send 在 transcript 下是被设计为合法
的(附件、举手、跨 bot @)。

闸改成 mode-aware(新增 replyDelivery 参数,缺省 'send' = 历史行为逐字节
不变),transcript 下三条路径一并翻转:

- 长度比例闸(2x + 120 字符)→ 换成「只有内容等同才算重复」。marker 只存
  归一化长度与截断预览、没有完整正文,因此判据是长度完全相等且预览是 final
  的前缀;长度不同一律送达。此前中途 send 7 字就要求 final ≥127 字。

- 「已 send + final 尾部 sentinel」硬抑制(不看长度)→ transcript 下不再适用。
  提示词仍教 sentinel,模型「发完附件说明 → 真答案写进 final → 习惯性补
  sentinel」必吞;剥掉 sentinel 后交给重复判定即可。

- 无 contentLength 的 marker 走 back-compat 分支直接抑制 → 不再抑制。这不是
  存量文件问题而是现役路径:`botmux send --images` 不带正文时
  buildBridgeSendMarkerContent 对空内容返回 undefined,--voice 的 marker 手工
  拼装同样没有该字段。重复一条消息远比静默吞答案便宜。

裸 sentinel、adopt、isLocal、markTimeMs=undefined 四条方向不变。

测试用 buildBridgeSendMarkerContent 真实构造 marker:手搓 { sentAtMs,
contentLength } 永远是结构化形状,会把第三条测成假阴性。覆盖三种形状
(--images 无正文 / 混合 / 正常正文),并钉住两个方向——prose+sentinel 送达、
内容等同仍抑制(长度相同但正文不同的也必须送达,防止 dedup 被一起改没)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXTEzQtcEcQkJzv1gNnxfa
评审复审 F2:markerSetDuplicatesFinal 里「空 final 一律抑制」的注释写着
「保持旧行为」,实际相反。emitReadyCodexTurns 会为**合成的**失败卡与空回合
诊断重跑这道闸,那时 gateInput.finalText 是空的(可见文案现合成在 content
里),于是 transcript 下即使模型一条消息都没发,失败原因也被压掉;而 send
模式对「空 final + 零 marker」是送达的(markerSetCoversFinal 在 markers 为空
时返回 false)。改为 `return markers.length > 0`,与 send 语义对齐:本轮确实
发出过东西才抑制。

structuredFallbackKind 一并接收 replyDelivery 并透传。不只是口径统一:失败
回合带一段 partial final、且中途发过一条较短 send 时,若 partial 没长过
2x+120 阈值,send 缺省分类会跳过 failed 判定落到 'final',只送 partial 而吞掉
失败原因;透传后 transcript 走它本该走的语义。

影响面窄:默认 transcript 的 claude-code 走 emitReadyTurns,空文本回合直接
continue,不合成这类诊断卡;只有显式配置 replyDelivery=transcript 的结构化
CLI 才触发。

另修 test/dashboard-ipc.test.ts:上一次 rebase 解冲突时把本 PR 的
`describe('PUT /api/bot-reply-delivery')` 整块插进了 master 那个
`describe('streaming card buttons')` 的 it 内部,两个块交错嵌套导致括号不闭合,
整个文件在 CI 上解析失败。改用「master 干净基底 + 整块插入到相邻 describe 之前」
重建,254 用例恢复通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXTEzQtcEcQkJzv1gNnxfa
@lRoccoon
lRoccoon force-pushed the pr/transcript-reply-mode branch from f89cb56 to 70d0cdf Compare September 9, 2026 09:02
@lRoccoon

lRoccoon commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

F2 已修,同时修了一个你们没提但更要命的问题:CI 上那 4 个红是我上一次 rebase 解冲突时弄坏的。

CI 红的根因(我的失误)

test/dashboard-ipc.test.ts 整个文件解析失败(`}` expected / Unexpected end of file)。上次 rebase 时我用「两边都保留」解冲突,把本 PR 的 describe('PUT /api/bot-reply-delivery') 整块插进了 master 那个 describe('streaming card buttons')it 内部,两个块交错嵌套。test (2/3)bun-test 栽的都是这一个根因。

已用「master 干净基底 + 整块插入到相邻 describe 之前」重建,254 用例恢复通过。

另两个红是 CI 资源问题,非代码缺陷:tmux-startup-storm-recovery 被 SIGKILL,⑤ chain 挂死:看门狗 ≤ 12s 实测 12217ms(超阈值 217ms)。

F2 修复

照建议改成 return markers.length > 0。你们指出「注释说保持旧行为,但并非如此」是对的——markerSetCoversFinalmarkers.length === 0 时返回 false(送达),我那行写成无条件 return true 确实反了,注释也就跟着错了。现在与 send 语义对齐:本轮确实发出过东西才抑制。

structuredFallbackKind 也接了 replyDelivery 并透传给 shouldEmitFailedBridgeFallback / shouldEmitEmptyCompletedBridgeFallback。你们说的那个中间窗口我认同:partial 比 send 长但没长过 2x+120 时,send 缺省分类会落到 'final' 只送 partial、吞掉失败原因。

补了测试钉住三个方向:空 final + 零 marker 在 transcript 下送达、send 模式同样送达(parity 断言)、有中途 send 时仍抑制。

rebase

已 rebase 到最新 master(9387fa19,含 #1331 / #1307 / #1334)。冲突确实只有 session-manager.ts 一处,就是你们预演的那个:保留本 PR 的 noTransport / transcript / bare 三个常量,函数名换成 #1307 改名后的 renderApplicationRoleBlock

bun run build 绿,PR 相关 5 个测试文件 460 passed。全量 unit 跑过一遍:36 个失败全部是环境类(bwrap / sandbox / cgroup / npm / 网络 / sqlite / tmux),无一落在本 PR 触及的模块。另外 dashboard-ipc.test.tscaps unauthenticated slow bodies 在多文件并发下会偶发失败,单独跑两次均 254 通过——时序敏感的 flaky,与本 PR 无关。

感谢这轮复审,尤其是把我那行注释和实际行为的矛盾挑出来。

@deepcoldy

Copy link
Copy Markdown
Owner

复审(70d0cdf0e F2 修复):两处修法都正确落地 ✅,补一条测试覆盖建议

自动评审初步意见,最终以维护者审阅为准。

基线:本分支已 rebase 到 #13349387fa190),merge 最新 master(30fdc7f39,含 #1314/#1335merge-tree 无冲突——上一轮 session-manager.ts 的改名冲突作者已自己解掉。

F2 两处修法均按预期实现

  1. 空 final 一行修 if (!finalNormalized) return markers.length > 0; —— 注释也改写到位,与 send 语义(markerSetCoversFinal 空 marker 集 → false 送达)对齐。新增用例「an empty final is delivered when nothing was sent」钉住了 send/transcript 双方:我把这行改回旧的 return true 打变异,测试转红(覆盖到位)。
  2. structuredFallbackKind 新增 replyDelivery 形参并透传给两个内部判定,worker 的唯一调用点(emitReadyCodexTurns)也传了 replyDeliveryMode()。我探针复核:失败回合带 partial final + 一条较短 send、partial 处于「长过 send 但没过 2x+120 阈值」窗口(如 partial 60–150 字 / send 50–100 字)时,现在正确分类为 'failed'(失败卡带 partial + 原因 POSTS),partial 远超阈值时两语义无差异。所有闸/分类调用点(6 个 shouldSuppressBridgeEmit + structuredFallbackKind)均已透传,send 模式靠缺省形参 + transcript-only 分支保持逐字节不变(74 个既有闸用例 + usage-limit 用例全绿)。

一个非阻断建议:第 2 处(透参)目前没有单元测试钉住。 我把 structuredFallbackKind 里的透传改回 send 缺省(两个判定各一次),整个 gate + usage-limit 套件仍然全绿(112/112)——即这处行为目前只靠人工探针保证,未来回归不会报警。建议在 describe('structuredFallbackKind') 里补 1–2 个用例:terminalStatus:'failed' + partial final + in-window 短 send marker,replyDelivery:'transcript' 应得 'failed'、缺省(send)得 'final'。逻辑本身已验证正确,只差回归网。

rebase 损坏的 dashboard-ipc.test.ts 已正确重建

这条很关键——上一版 rebase 曾把 PUT /api/bot-reply-delivery 的 describe 块插进 master 的 streaming card buttons 用例内部、括号不闭合导致整个文件 CI 解析失败(bun run build 照过,因为测试文件不参与 tsc,这也是它没被本地 build 挡住的原因)。我核对了重建结果:describe 集合相比 master 零丢失、零多余(仅新增 bot-reply-delivery 一个),新块是顶层 describe、未嵌套进相邻块,块内 3 个 IPC 用例完整闭合;该文件实跑 254 passed

验证bun run build 通过;本 PR 全部 26 个测试文件隔离跑 1930 passed / 1 skipped。全量套件另有 36 个失败,全部落在本 PR 未触碰的文件(sqlite 的 bun 版本断言、root 身份下 EACCES 不 throw、bwrap 在 worktree 不可用——均为这台机器的既有环境噪声,干净 master 同样红),与本 PR 无关。

辛苦,F1/F2 至此都收敛了。

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