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 queue | Harness(zustand store,按 Agent 隔离) | 由Munder Difflin暂存、等待该 Agent 终端空闲的消息 |
| Claude queue | Claude 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 ms(
setTimeout(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):
- 将该消息标记为
manual: true; - 移到队首;
- 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 判定。以下任一为真就拒绝交付:
| 阻塞类型 | 由什么触发 | 由什么解除 |
|---|---|---|
exited | PTY 进程退出 | 重新 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 allow、implement this判定为 false。
3.2 30 分钟过期窗口:宁可多等,不可误判
picker和draft都会在30 分钟后过期(STALE_PICKER_MS与STALE_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.active中baseY + 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)。
三个关键设计:
- 它不是 Agent 状态,也从不存到 Agent 上——那个字段归 PTY 解析器所有,存上去会被覆盖。它是在渲染时从 gate 使用的同一套草稿检测派生出来的,所以徽章报告的就是 gate 看到的同一个草稿。
- 渲染端用 useHasTerminalDraft 以 1 秒间隔轮询(标志活在可变池条目上,没有组件订阅它;每次只读一行缓冲,开销可忽略,且徽章一秒的延迟不可见)。
- 它不应用gate 的 30 分钟过期。窗口过后 gate 开始投递而徽章仍显示 "your draft"——这反而诚实:你的文本确实还在提示符上。徽章回答的是"我的文本在不在",而不是"队列有没有被阻塞"。
6. 完整调用链:一条消息从入队到落进终端
综合前面所有环节,一条消息的生命周期如下:
- 入队:任意来源(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 的队列尾部,并持久化。 - 调度:store 变化触发防抖 200 ms 的
flush();3 秒兜底定时器兜底。flush对每个有队列的 Agent 调用dispatch。 - 门控:
dispatch依次检查 idle/quiet、autoDeliveryPaused(除非manual)、boot-grace、isTerminalAutomationSafe、4.5 s 冷却、交付前precondition复查。 - 写入:通过 submitToPty 写入。同一 PTY 的写入被串行化(
writeChainspromise 链),防止并发调用者把输入挤在一起;多行文本包 bracketed-paste,单行文本裸发(部分 TUI 如 Antigravity 的 agy 会把粘贴标记当字面输入)。 - 确认:两次写入(文本 +
\r)都成功后才removeQueuedMessage;失败保留重试,3 次后带 warn 丢弃。Slack 来源的消息在首次派发时还会被提升为 kanban 卡片(ensureSlackCard)。
7. 代码地图:每个文件扮演的角色
| 文件 | 角色 |
|---|---|
| terminalAutomation.ts | 纯策略——阻塞判定、过期窗口、交互式命令识别。无 DOM,完全单元测试覆盖 |
| terminalPool.ts | 按 PTY 池化的 xterm 实例;缓冲读取、闩锁、isTerminalAutomationSafe、hasTerminalDraft |
| useHive.ts | effect #3 收件箱 nudge(入队)、effect #4 drain(唯一写入者)、effect #6 定时 /compact |
| store.ts | MD 队列本体 + 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. 设计哲学小结
读完整个消息队列契约,可以提炼出几条贯穿始终的设计原则:
- 单一写入者。一个 drain loop 独占"终端是否空闲"的决策权,任何新功能要触达 Agent 终端,先入队再说。
- 用户的提示符神圣不可侵犯。自动化永不擦除用户文本、永不关闭用户菜单;两个 30 分钟窗口刻意设长,因为"多等"比"误删"便宜得多。
- 屏幕是更好的证据,但要单向使用。缓冲读取只能清除幽灵草稿、不能发明草稿,并把回显间隙排除在证据之外。
- 失败的交付必须可见。两次写入都成功才确认;失败保留重试,有界、有声地丢弃,绝不静默吞掉消息。
- 昂贵的错误由人来做。关闭 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),仅供参考