Munder Difflin 消息队列剖析:一条指令如何安全地键入 Agent 终端
2026/9/17 9:54:02 网站建设 项目流程

Munder Difflin 消息队列剖析:一条指令如何安全地键入 Agent 终端

【免费下载链接】munder-difflinA local multi-agent harness that works with your existing Claude Code, Codex subscriptions, allows you to run an office of agents项目地址: https://gitcode.com/GitHub_Trending/mu/munder-difflin

Munder Difflin 的每个 Agent 都在真实 PTY 中运行真实的 CLI(Claude Code、Codex 等),而这条输入行同时被用户和 Harness 争夺。本文以仓库文档 docs/message-queue.md 为骨架,完整讲解 Munder Difflin 的MD 消息队列契约:谁有权键入、何时键入、为什么这样设计,并深入 useHive.ts 的 drain loop、terminalAutomation.ts 的纯策略层与 terminalPool.ts 的终端池实现,还原从入队、门控到最终写入 PTY 的完整调用链。


1. 一锤定音:只有一个"门"能自动往 PTY 里打字

1.1 两个同名却完全不同的队列

文档开篇就划清了概念边界——项目里有两个都被称作 "the queue" 的东西,但它们是两回事:

名称存在于存放内容
MD queueHarness(zustand store,按 Agent 隔离)Munder Difflin暂存、等待该 Agent 终端空闲的消息
Claude queueClaude Code 进程内部Claude Code 已接收但尚未开始处理的文本

本文全部内容围绕MD queue。Harness 既看不到 Claude queue,也从不参与它的调度——那是 CLI 自己内部的消费队列。

1.2 唯一写入者:drain loop

Munder Difflin 中,自动把消息键入运行中 Agent 的 PTY 的唯一位置是 drain loop,即 useHive.ts 的 effect #4。所有想触达运行中 Agent 的写入方——composer、Slack 入口、收件箱 nudge、定时 /compact——都统一enqueueMessage(agentId, text)入队,然后把"何时交付"的判断权全部交给 drain:

composer / Slack ingress ─┐ inbox nudge (effect #3) ──┼──▶ enqueueMessage(agentId, text) ──▶ MD queue ──▶ drain (#4) ──▶ PTY scheduled /compact (#6) ──┘

这是承重设计(load-bearing)。文档记录了一个真实事故:当收件箱 nudge 直接写终端时,它成了第二个写入者,对"提示符是否空闲"有自己的一套判断——结果它的文本落在用户写到一半的行上,nudge 和用户句子被拼成一条乱码提示一起提交。改为统一入队后,一个循环独占所有"终端是否空闲"的决策,nudge 自身不再需要任何提示符逻辑。

唯一例外是 god Agent 的启动序列(useHive.ts):它会直接写入/remote-control命令和 orientation prompt,因为那个 PTY 是几百毫秒前刚 spawn 的,处于 boot-grace 窗口保护之下,不存在用户草稿会被覆盖的问题。源码中BOOT_GRACE_MS = 35_000(useHive.ts),期间所有自动写入方都会被挡在门外,等 TUI 先画完它的 banner。

1.3 触发节奏:防抖 + 兜底

drain 的调度方式(useHive.ts):

  • 每次 store 变化都会触发,但防抖 200 mssetTimeout(flush, 200)),让 PTY 输出突发合并在一次 flush 里;
  • 同时存在一个3 秒兜底定时器setInterval(flush, 3000)),保证即使没有任何 store 变化,队列也不会被饿死;
  • 每次 dispatch 之间还有4500 ms 的发送冷却FLUSH_COOLDOWN_MS),避免背靠背发送把 TUI 卡死。

2. drain 到底检查什么:六道关卡

队列头部的消息只有在全部条件成立时才会被交付。综合 docs/message-queue.md 与 drain 实现(useHive.ts),完整关卡如下:

条件原因
Agent 状态为idle不打断进行中的 turn
自动交付未暂停——或该消息被手动放行楼层级开关(Command Center),见下文
已过 boot-grace 窗口CLI 还在绘制 banner
isTerminalAutomationSafe(ptyId)为真用户拥有提示符,见第 3 节
距该 Agent 上次交付 ≥ 4.5 s背靠背发送会卡死 TUI
交付前重新检查消息的precondition队列项在入队时判定、交付时可能已失效

2.1 手动放行(v0.3.5 引入)

当楼层级自动交付被暂停时,每条排队消息都会显示一个send now链接。点击后(store 中的 releaseQueuedMessage):

  1. 将该消息标记为manual: true
  2. 移到队首
  3. drain 只为它绕过暂停检查——表中其余所有条件依然生效。

也就是说,手动放行的消息仍然要等待 idle、仍然尊重你的草稿和 picker、仍然以同样的方式确认交付。暂停闸门则拦住其他所有消息。源码注释明确写道:"Idle/draft/picker safety below still applies to manual messages; only the pause is bypassed"(useHive.ts)。

2.2 只有两次 PTY 写入都成功才确认交付

交付确认发生在两次 PTY 写入都成功之后:先写文本(多行文本会包上 bracketed-paste 标记ESC[200~ … ESC[201~,防止嵌入的换行被当作回车逐行提交),等待 140 ms,再写入提交用的\r(useHive.ts)。

writePty对死 PTY从不 reject,而是返回{ ok: false }——源码注释记载了因此修复过的真实 bug(#36):未检查返回值会让失败的交付看起来成功,drain 于是销毁了实际上从未送达的消息。现在失败的写入会抛错,消息保留在队列中可见并自动重试,最多重试MAX_SEND_ATTEMPTS = 3次,超过后带着console.warn被丢弃——有界,避免 drain 在一具尸体上永远空转;有声,保证损失可诊断(useHive.ts)。

2.3 队列层的幂等防抖

store 的 enqueueMessage 还内置两条去重规则:

  • 每 Agent 至多一条待交付/compact——compact 是"最坏意义上的幂等",第一条生效后其余都只会得到"nothing to compact",白白消耗一次交付槽和一次模型往返;
  • 每 Agent 至多一条收件箱 nudge——第一条 nudge 就会让 Agent 清空整个收件箱,后面的 nudge 只会落在一个已经清空的目录上。压抑副本不丢任何信息:存活的那条 nudge 会把 Agent 带到同一个权威目录。

这两条不变量收敛在 store 层而非各调用点,是因为调用点太多(上下文触发、god 派单、Slack、composer),任何一处自行检查都可能被下一条新路径绕过。


3. 用户拥有提示符:isTerminalAutomationSafe 的四道闸

isTerminalAutomationSafe(terminalPool.ts)是保护你打字的安全闸。它把终端状态投影成TerminalAutomationState,交给纯策略函数 terminalAutomationBlock 判定。以下任一为真就拒绝交付:

阻塞类型由什么触发由什么解除
exitedPTY 进程退出重新 spawn
picker你提交了裸的/model类命令(打开菜单)在该终端键入 Enter / Escape / Ctrl-C,或过期
draft提示符上有未提交文本提交或清空,或过期
settling行被释放后短暂的 TUI 重绘窗口时间(500 ms)

3.1 picker 的判定:只有裸命令才算

opensInteractiveTerminalUi(terminalAutomation.ts)对裸命令才返回 true:/model会打开选择器,而/model sonnet带参数直接应用并回到提示符,没有需要关闭的 UI。源码注释记录了一个教训:只匹配第一个 token 会把第二种形式也锁住,而锁一旦锁上没有任何路径能清除——该 Agent 的消息队列在后半场会话中悄悄停止投递。匹配实现只认不含空白的完整裸命令:

const trimmed = input.trim().toLowerCase(); if (/\s/.test(trimmed)) return false; return INTERACTIVE_COMMANDS.has(trimmed);

完整的交互式命令清单(INTERACTIVE_COMMANDS)包括:/model/reasoning/permissions/permission/provider/settings/config/experimental/experiments/hooks/mcp/apps/plugins/resume/sessions(terminalAutomation.ts)。

测试 test/terminal-automation.test.cjs 明确验证了这一对行为:/model/provider判定为 true,而/model sonnet/permissions allowimplement this判定为 false。

3.2 30 分钟过期窗口:宁可多等,不可误判

pickerdraft都会在30 分钟后过期(STALE_PICKER_MSSTALE_INPUT_MS均为1_800_000ms,terminalAutomation.ts)。过期机制存在是因为两个标志都是推断而非上报的:一个以无法观察的方式关闭的 picker,或一个被 TUI 吞掉按键而残留的 draft 标志,都会让该 Agent 的 MD 队列在此后整个会话中被卡死。

过期行为有两条铁律:

  • 自动化永不擦除你的文本。过期意味着排队消息被键入到行上已有内容之后,两者融合成一条提示。早期版本会先发 Ctrl-U,结果静默销毁了那些只是放了一分钟的真实草稿。
  • 自动化永不关闭你的菜单。面对 picker 我们不发 Escape。你可能是有意打开后走开的;为了让位给排队消息而关闭它,不是 Harness 该做的决定——而且我们根本无法验证 Escape 是否真的关掉了菜单。composer 里有一个专门的按钮做这件事,因为那样是请求的。

源码中clearTerminalDraft的注释补充了另一层谨慎(terminalPool.ts):Ctrl-U 清行时不清除automationBlocked闩锁——Ctrl-U 只杀输入行,不会关闭已打开的 picker。早期版本清了它,导致排队消息被键入到 picker 里还被确认为"已交付"——消息丢失,picker 收到垃圾。

两个窗口都刻意设得很长。把活跃草稿当作废弃是昂贵的错误;让排队消息多停一会儿是便宜的错误。测试断言"十分钟后依然归用户所有,半小时后才放行"(test/terminal-automation.test.cjs)。


4. 读屏幕而非建模:promptLineHasText 的单向校正

4.1 幽灵草稿问题

inputDirty是通过在term.onData计数按键推断的(terminalPool.ts)。这个模型会漂移:一个把按键吞进自己 UI 的 TUI,会在提示符肉眼可见为空时让计数停在非零——这就是幽灵草稿(phantom draft),它会把 MD 队列挡住,而实际上并没有草稿存在。

4.2 直接读渲染缓冲

xterm 已经持有渲染好的屏幕,所以 promptLineHasText 直接读取它:取term.buffer.activebaseY + cursorY那一行,用PROMPT_CHROME正则(/[─-╿\s>❯$#|]/g)剥掉 TUI 画在输入行周围的装饰框和提示符标记,剩下的非空即认为有文本。

它是刻意单向的:缓冲读取只能清除草稿,绝不能发明草稿。

  • 屏幕显示为空 ⇒ 相信它,解除阻塞;
  • 屏幕显示有文本,或读不到 ⇒ 回退到按键计数。

这个不对称是设计的核心,因为两种错误的代价不同:错误的"空"会打开闸门,把排队消息融合进你正在写的东西;错误的"有文本"只是把消息停到草稿过期。所以允许发生的恰恰是便宜的那种错误。

4.3 ECHO_GRACE_MS:回显间隙不是证据

有一个场景屏幕完全不能当证据:inputDirty在你按下按键的瞬间就被置位,但字符要等 PTY 回显后才到达 xterm 的缓冲。在这个间隙里,缓冲仍显示旧状态——对刚开头的草稿读取会返回"空",也就是昂贵的那个方向。因此,距最后一次按键 **1 秒内(ECHO_GRACE_MS = 1000)**的读取返回"不知道"(null),不清除任何东西(terminalPool.ts)。

4.4 配套的解析细节

term.onData中还维护了一个与inputDirty同步的lineBuf(当前行模型):

  • 裸 Escape / Ctrl-C:释放 picker 闩锁并清空 lineBuf(箭头键的转义序列不会清除,否则用户在 picker 里导航时阻塞会被误清);
  • 用户自己的 Ctrl-U(kill-line):清空 lineBuf,与我们发送的行为完全一致;
  • bracketed paste:只剥掉包裹标记,粘贴内容仍视为用户草稿——ESC[200~开头意味着这是一次粘贴而非提交,其内嵌换行不会被当成多次提交。

每次按键都会重新盖时间戳inputDirtyAt,所以过期时钟度量的是"用户最后一次触碰草稿至今"而非"开始至今"。


5. 看见"为什么没在投递":typing 徽章

一个被卡住的 MD 队列过去看起来和一个无事可做的 idle Agent 一模一样。typing徽章(CSS 类--cth-status-typing,文案"your draft")解决了这个问题:只要hasTerminalDraft(ptyId)为真,它就会渲染在 Agent 卡片和全屏 roster 上(terminalPool.ts)。

三个关键设计:

  1. 它不是 Agent 状态,也从不存到 Agent 上——那个字段归 PTY 解析器所有,存上去会被覆盖。它是在渲染时从 gate 使用的同一套草稿检测派生出来的,所以徽章报告的就是 gate 看到的同一个草稿。
  2. 渲染端用 useHasTerminalDraft 以 1 秒间隔轮询(标志活在可变池条目上,没有组件订阅它;每次只读一行缓冲,开销可忽略,且徽章一秒的延迟不可见)。
  3. 不应用gate 的 30 分钟过期。窗口过后 gate 开始投递而徽章仍显示 "your draft"——这反而诚实:你的文本确实还在提示符上。徽章回答的是"我的文本在不在",而不是"队列有没有被阻塞"。

6. 完整调用链:一条消息从入队到落进终端

综合前面所有环节,一条消息的生命周期如下:

  1. 入队:任意来源(composer、Slack ingress useHive.ts、收件箱 nudge effect #3 useHive.ts、voice bridge、上下文触发 effect #6 useHive.ts)调用 store 的enqueueMessage,消息以QueuedMessage(含 id、text、ts、可选的 slack/instruction/precondition/compactUsed 元数据)追加到该 Agent 的队列尾部,并持久化。
  2. 调度:store 变化触发防抖 200 ms 的flush();3 秒兜底定时器兜底。flush对每个有队列的 Agent 调用dispatch
  3. 门控dispatch依次检查 idle/quiet、autoDeliveryPaused(除非manual)、boot-grace、isTerminalAutomationSafe、4.5 s 冷却、交付前precondition复查。
  4. 写入:通过 submitToPty 写入。同一 PTY 的写入被串行化writeChainspromise 链),防止并发调用者把输入挤在一起;多行文本包 bracketed-paste,单行文本裸发(部分 TUI 如 Antigravity 的 agy 会把粘贴标记当字面输入)。
  5. 确认:两次写入(文本 +\r)都成功后才removeQueuedMessage;失败保留重试,3 次后带 warn 丢弃。Slack 来源的消息在首次派发时还会被提升为 kanban 卡片(ensureSlackCard)。

7. 代码地图:每个文件扮演的角色

文件角色
terminalAutomation.ts纯策略——阻塞判定、过期窗口、交互式命令识别。无 DOM,完全单元测试覆盖
terminalPool.ts按 PTY 池化的 xterm 实例;缓冲读取、闩锁、isTerminalAutomationSafehasTerminalDraft
useHive.tseffect #3 收件箱 nudge(入队)、effect #4 drain(唯一写入者)、effect #6 定时 /compact
store.tsMD 队列本体 + Agent 持久化;enqueueMessage/removeQueuedMessage/releaseQueuedMessage/clearQueue
terminal-automation.test.cjs纯策略层的单元测试:交互式命令识别、四类阻塞、过期语义、阻塞优先级

如何验证这套行为

策略层是完全脱离 DOM 的纯函数,可以在 Node 中直接运行测试:

node --test test/terminal-automation.test.cjs

该测试验证了本文讨论的核心语义:/model阻塞而/model sonnet不阻塞;draft/picker/exited/settling 四类阻塞;30 分钟过期后放行;picker 优先级高于 draft(两者同时为真时报picker)。仓库根目录 package.json 提供了完整的测试脚本入口,load-ts.cjs(test/load-ts.cjs)负责把 TS 源转译后加载进node:test


8. 设计哲学小结

读完整个消息队列契约,可以提炼出几条贯穿始终的设计原则:

  1. 单一写入者。一个 drain loop 独占"终端是否空闲"的决策权,任何新功能要触达 Agent 终端,先入队再说。
  2. 用户的提示符神圣不可侵犯。自动化永不擦除用户文本、永不关闭用户菜单;两个 30 分钟窗口刻意设长,因为"多等"比"误删"便宜得多。
  3. 屏幕是更好的证据,但要单向使用。缓冲读取只能清除幽灵草稿、不能发明草稿,并把回显间隙排除在证据之外。
  4. 失败的交付必须可见。两次写入都成功才确认;失败保留重试,有界、有声地丢弃,绝不静默吞掉消息。
  5. 昂贵的错误由人来做。关闭 picker 的按钮放在 composer 里,只有用户自己可以按。

这套契约是 Munder Difflin 能让用户连续向运行中的 Agent 发消息、却从不在打字时被自动消息干扰的根基——它不依赖任何 CLI 的配合,纯粹在 Harness 这一侧用可验证的、带失效机制的推断,守住了"一条消息只能出现在它该出现的地方"这条底线。

【免费下载链接】munder-difflinA local multi-agent harness that works with your existing Claude Code, Codex subscriptions, allows you to run an office of agents项目地址: https://gitcode.com/GitHub_Trending/mu/munder-difflin

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询