munder-difflin 的多 agent 协调实现
AgentElectron架构刷到这个仓库的时候是被名字骗进去的,munder-difflin,《办公室》里那家最差的纸业公司,主管 Michael 也搬了过来当了这层楼的老板。演示视频里一堆像素小人在办公室里走来走去,互相发消息的时候还有信封在桌子之间飞,看着像个玩具。
后来翻到一份叫 docs/message-queue.md 的文档,一千多字只讲一件很窄的事:一个真实的 CLI 只有一根输入行,而想用它的不止你一个。这篇就记一下里面几处认真想过的地方。
项目概况
munder-difflin 是个 Electron 桌面应用,把你已经在终端里跑的 agent CLI 包起来当 agent 用,README 里列了十种:claude、agy、codex、grok、kimi、qwen、opencode、crush、pi、copilot。每个会话是一个真进程,同时是办公室地板上的一个小人,走去哪个工位取决于它在用哪个工具。
到今天的 HEAD(92461ab),从 5 月 31 日起 710 次提交,src/ 下 185 个 ts/tsx 文件、53,166 行,index.ts 4,582 行,hive.ts 2,464 行,38 个测试文件跑在 node --test 上。版本 0.4.4,README 自己写的状态是 working prototype,这个自我定位是准的,功能铺得很宽,有些地方深得意外,有些地方文档已经和代码不一样了。
两个数据平面
它把数据分成两条路,一条给画布,一条给终端。
终端那条是主进程用 node-pty 把每个 agent 拉起成真进程,输出按 id 走 IPC 推给渲染进程,xterm.js 渲染,逐字节真实,你能直接往里打字。
事件那条是每个 agent 启动时用 --settings 把钩子指向一个自带的 shim,shim 把生命周期负载写进主进程监听的本地 socket。POSIX 上是 hive 目录里的 hooks.sock,Windows 上换成按 hive 路径 sha1 命名的具名管道。PreToolUse 和 PostToolUse 决定小人走去哪个工位,Notification 决定它是不是在挥手叫你。SPEC.md 里写了为什么两条都要:光有钩子给不了用户想看的原始字节流,光有终端输出流又没法知道当前在跑哪个工具,除非去解析输出,很脆。分开之后画布是事件驱动的,终端视图是原始字节的,两者不共享判断。
顺带一提,SPEC.md 通篇讲的还是用 tmux 附着到你已有的窗格,tmux pipe-pane 读、send-keys 写,还明确写着不 spawn、不拥有 claude 进程。实际发布的东西自己 spawn PTY、自己管生命周期,那份 spec 现在只能当考古材料看。
蜂巢
多 agent 那部分叫 hive,落在 <harnessHome>/hive/,是个本地 git 仓库。
目录结构
hive/
PROTOCOL.md # 给 agent 看的协议
registry.json # 名册
board.md # 共享黑板
tasks.json # 任务台账
log.jsonl # 追加写的事件流
agents/<agentId>/
identity.md # 我是谁
memory.md # 我的长期记忆
inbox/ # 别人发给我的
outbox/ # 我要发出去的
cursor.json # 处理到哪了规则有四条,看得出来每条背后都有个坑。
只有主进程提交。十几个进程并发 git commit 撞的是 .git/index.lock,仓库会坏,所以 agent 一律不碰 git 只写普通文件。提交那段重试 5 次,退避 50 * (attempt + 1) 毫秒,mtime 超过 10 秒的 index.lock 当残锁直接删掉。
每个文件只有一个写者。agent 只写自己的 agents/<id>/,跨 agent 的投递由主进程的路由器搬,从发信人的 outbox/ 搬进收信人的 inbox/,1.5 秒轮询一次。一条消息一个 JSON 文件,写临时文件再 rename,不是往一个共享邮箱文件里追加,那种在 git 下必然冲突。log.jsonl 只追加,消费者各自记游标。真正被多方编辑的只有 board.md,办法是让编排 agent 当唯一的抄写员。
消息格式
借了 FIPA-ACL 里的言语行为,request、inform、propose、query、agree、refuse、done,LISP 语法丢掉了。防活锁三条:只有 request、query、propose 有义务被回复,inform 和 done 是终止的;每次回复 hops 加一,超过 HOP_CAP = 12 路由器直接丢弃并记一行 drop;重复见到已处理的 id 靠 cursor.json 变成幂等空操作。
终端输入的排队与放行
docs/message-queue.md 讲的就是这个。
它先把两个都叫队列的东西分开:harness 自己的 MD 队列,在 zustand 里,每个 agent 一条,存着被暂存的自动消息;以及 Claude Code 内部的队列,它已接受但没开始做的文本。harness 看不见后者,也从不对它做推理。
唯一的写入口
所有想碰到运行中 agent 的路径,composer、Slack 入口、inbox 提醒、定时 /compact,统统 enqueueMessage 入队,由一个 drain 循环决定何时放行。文档里写了为什么必须这样:
当 inbox 提醒直接往终端写时,它就是第二个写者,有它自己关于「输入行何时空闲」的判断——于是它的文本落在了用户写了一半的那行上面,提醒和用户的句子被当成一个乱七八糟的 prompt 一起提交了。
放行要五个条件同时成立:agent 状态是 idle,没有全局暂停(或者这条消息被手动放行了),过了 35 秒开机宽限,isTerminalAutomationSafe(ptyId) 通过,距上次投递超过 4.5 秒。两次 PTY 写入(文本、回车)都成功才算送达,失败就留在队列里重试。
阻塞条件与过期
isTerminalAutomationSafe 在四种情况下拒绝投递:PTY 已退出,你打开了菜单,你有未提交的草稿,以及刚放开输入行的短暂重绘窗口。菜单和草稿这两个都是推断出来的不是被上报的,所以 30 分钟后过期,不然一个没被观测到的菜单关闭就能把这个 agent 的队列卡到会话结束。
过期之后的行为写了两条规矩。一是排队的消息被打在你那行后面,两者融成一个 prompt,不会先清屏——早先的版本会先发 Ctrl-U,销毁过真实的草稿,那些草稿只是被放置了一分钟。二是不会往菜单发 Escape,因为你可能是故意打开它然后走开的,为了给一条排队消息腾地方把它收掉不是 harness 该做的决定,而且也没法确认 Escape 真的生效了。
草稿检测
inputDirty 靠数 term.onData 的按键推断,这个模型会漂移,一个自己吃掉按键的 TUI 会让计数停在零以上而屏幕上其实是空的,文档里管这叫幽灵草稿,它会挡住一条本该发出去的消息。修法是直接读 xterm 已经渲染好的屏幕缓冲,term.buffer.active 里光标那一行,剥掉提示符装饰。
关键是这个读取只单向生效。屏幕说空就相信它撤掉阻塞;屏幕说有字、或者读不出来,就退回按键计数。理由是两种错误的代价不一样,误判为空会开门并把消息糊到你正在写的句子上,误判有字只是让那条消息多等一会,所以让便宜的那种错发生。
还有个更细的:inputDirty 在你按下键那一刻就置位,但字符要等 PTY 回显才进 xterm 的缓冲,这个间隙里读缓冲会得到空,正好是贵的那个方向,所以距上次按键 1 秒内的读取返回「不知道」,什么都不清除。
Windows 上的多行参数截断
v0.4.4 的发布说明标题是 Windows 上的 agent 终于能互相说话了,pty.ts 里为这事写了很长一段注释。
起因
Windows 上 .cmd 和 .bat 不能直接交给 CreateProcess,所以 spawn 走 cmd.exe /d /s /c "<line>",argv 变成一个由 cmd.exe 解析的单字符串。cmd.exe 把 CR/LF 当语句分隔符,在考虑引号之前就处理,所以多行参数会被截断在第一个换行,剩下的当命令执行;( 和 ) 读成块分隔符;没有反斜杠转义;命令行上限约 8191 字符。
而注入的那段 hive 协议提示词是 6.1k 字符、11 行、62 个括号。
结果是每一个用 npm 装的 CLI,OpenCode 永远如此,claude.cmd 在没有原生 claude.exe 时也是,启动起来看着完全健康、界面正常,但从来没收到过 HIVE PROTOCOL 那一段,从来不知道 inbox/ 和 outbox/ 存在,于是没有任何一个 agent 听到过另一个 agent 说话。Claude 看起来能用只是因为它的原生 claude.exe 绕过了 cmd.exe。
修法
把 npm 的 .cmd shim 解码回它真正要跑的解释器加脚本,然后用参数数组去 spawn,这样 node-pty 自己那套符合 MSDN/CRT 规则的 argsToCommandLine 生效,直接交给 CreateProcess,中间没有 shell 解析器。引号里的换行在那儿只是个普通字符,上限也从 8191 变成 CreateProcess 的 32767。parseNpmCmdShim 写成了纯函数,只吃 shim 路径和内容,不碰文件系统不看 process.platform,所以能在 macOS 和 Linux 上单测,Windows 构建通常就是在那儿写的。它匹配了 npm cmd-shim 历史上三代产物的形状,看不懂的形状就返回 null 退回 cmd.exe 路径,保证不比原来更差。
第二次修
14cb1e4 这次。opencode-ai 的 bin 指向 ./bin/opencode.exe,一个编译好的二进制不是 JS 脚本,npm 于是写出一个没有解释器的 shim:
"%dp0%\..\opencode-ai\bin\opencode.exe" %*解析器只建模了两个 token 那种形状,这里只有一个带引号的 token、前面什么都没有,于是返回 null,笔直掉回那条会截断的路径,每一台 Windows 上的 OpenCode 安装都确定性地中招。提交信息里写的是 agent 启动了、渲染了、看起来很健康,而且完全不知道自己有一个收件箱。
同一条提交里还改了另一件事,这个 bug 能活过一次发布的真正原因是 cmd.exe 回退路径是静默的,现在它会警告,并且报出没能解码的目标以及这次 spawn 里是否真有多行参数处在风险中。个人觉得这个改动比修解析器本身更有用。
编排 agent
Michael 是个普通的 claude 进程,坐在 desk-ceo,标记 isGod。设计文档里分工写得很清楚,它是智能,主进程是机制,也就是 git、socket、路由那些。它管名册和路由(registry.json)、裁决(读每条对外请求,例行的自己解决,只有关键的才升级)、当 board.md 的唯一抄写员、维护任务台账。
什么算关键——破坏性操作、花真钱、范围变更、无法解决的冲突——写在它的系统提示词里而不是代码里,文档明说这是主要的控制面,要调的是提示词不是代码。代价它也承认了:没有独立的审批队列,人类介入靠每个 agent 自己 Claude Code 会话里的工具权限提示,可以从手机上通过 /remote-control 远程批。也就是说安全边界的可靠性等于那段提示词的可靠性加上权限提示的覆盖度,对一个五到十五个 agent 的个人 floor 也许够用。
钩子服务器还会在 SessionStart 和每次 UserPromptSubmit 时把当前名册作为 additionalContext 塞进去。注释里解释了原因:fleet.json 在磁盘上永远是新的,但它的上下文不是,重启后接上一份描述旧楼层的 transcript,然后去给早就不在的 agent 发消息。这种问题只有真跑过才会碰到。
提示词前缀的两条约束
hive.ts 里 injectedPrompt() 上面压了一段带锁标记的注释,说这个系统提示词前缀必须保持无易变量,只插值那些在一个 agent 整个生命周期里稳定的值,不要加日期、UUID、计数器、board 或 registry 状态、任何 Date.now() 派生的文本,因为一个每次 spawn 都变的前缀会打掉 prompt cache,每一轮都要重新预热整个系统提示词。易变的上下文走 inbox 和 PTY,不烤进前缀。
紧跟着还有一段讲不许用 shell 语法:注入给 agent 的每条路径和命令都必须是它在自己那个平台上真会敲的样子。$VAR 在 cmd.exe 下展开成空,所以那些指令在每一台 Windows 上都是死的;用字符串拼 '…' + '/inbox/' 会告诉 Windows agent 去读 C:\Users\x\hive\agents\god/inbox/。改成烤进 join() 出来的绝对路径,跨平台、不需要展开、而且对 prompt cache 仍然稳定。
记忆与压缩
底座是每个 agent 的 memory.md 加共享 board.md,纯 markdown。文档直接否掉了重型向量层,理由两条,五到十五个 agent 的规模上不需要,以及架构上不对,那些东西想拥有 agent 运行时而这里的运行时是 CLI 本身。
语义层是 memory.ts 包的 MemPalace CLI,整个 hive 共用一个 palace,把每个 agent 的 memory.md 挖进它自己的 wing,按 mtime 判断要不要重挖,mempalace 没装就整体退化成空操作,markdown 记忆照常工作。顺带一个细节,mempalace mine 尊重 .gitignore,所以它往每个 agent 目录扔一份 .gitignore 排掉 settings.json、cursor.json、inbox/、outbox/,而不是去改 mine 命令,理由是那个钩子配置是个大 JSON blob,会把摘要冲掉。
压缩那半在 reflect.ts,它自称是清洁工缺掉的 CONDENSE 一半,清洁工会标记超大的 memory.md 但从不真的缩小它。压缩把文件重写成有界的三段,钉住的持久事实永不动、一份滚动的递归摘要、最新的 K 段原文,用便宜的无头 claude -p 加 claude-haiku-4-5 总结被驱逐的尾巴,预算 128 KB。安全性是先备份一份无损冷拷贝、再验证、最后原子替换,任何一步失败原文件逐字节不动,只留一行 condense-abort 日志。还有条注释解释了为什么这个循环必须待在 Electron 主进程而不是 launchd:macOS 的 TCC 会拦住 launchd 拉起的 shell 访问 ~/Documents,只有这个进程拿到了文件夹授权。
断路器与成本核算
breaker.ts 开场就说了动机,Claude Code 有 --max-turns 但没有美元上限,所以自己造一个。
这个模块只管策略,触发条件加 steer、constrain、stop 三级升级阶梯,没有副作用,读信号返回决定,执行由 index.ts 里的心跳去做。输入聚合三路:用量采样看成本与 token 速率,钩子事件看重复的同名同参 PostToolUse 和 api_error 风暴,以及文件 mtime 看有没有进展。速率用的是连续累计采样的差分,不是把单次采样当增量。
默认值挺能说明性格:hardStop 默认是关的,没开的话阶梯顶到 constrained 就不会杀进程;一个心跳只升一级,绝不跳到 kill;健康的心跳降一级;token 速率兜底定在每分钟 60,000 output tokens,注释自称故意定得很高的粗糙兜底;无进展要连续两个心跳才算,一次 inbox ack 或 statusline 突发不该单独触发。还有个上下文压缩豁免,PreCompact 打开它让压缩带来的 token 爆发不触发那几个臂,PostCompact 或任何 SessionStart 关掉它并留 90 秒尾巴。
成本那边 transcript.ts 直接读 ~/.claude/projects/ 下的 JSONL transcript 拿真实 token,不是估的。里面有个兼容坑,Claude Code 的 project key 是把 cwd 里每个非字母数字字符换成短横,/Users/me/app 变成 -Users-me-app,而 2026 年之前的 POSIX 写法是丢掉前导斜杠只换斜杠,点号会活下来,所以它把两种 key 都留着,只为了让改动之前写的 transcript 还能读。
账本不该进 git
hive.ts 里有段关于 cost-ledger.jsonl 的注释,说的是这文件只追加、每次用量采样加一行,而 hive 一直在提交,一个跟踪它的仓库会在每次提交里存一份整个文件的新拷贝。它算得很具体,一个四分之一 GB 的账本加几千次提交就是几百 GB 的 blob 要 git 去走,这就是一次例行 gc 变成多 GB pack-objects 的原因。真正的坑在下一句:git 对已经在索引里的文件会继续记录,不管 .gitignore 说什么,所以只加一行 ignore 读起来像修好了而仓库照旧在长。所以它专门做了一次 rm --cached,而且动手之前先探测有没有真的被跟踪,免得每次启动都在重试路径里重写索引。
文档与代码的偏差
HIVE.md 把「自治循环等于 Stop 钩子」列为锁死的设计决策之一,Phase 1 标着已完成:agent 干完一轮,Stop 钩子返回 {"decision":"block","reason":…} 让它接着干,靠 stop_hook_active 防死循环。
代码里已经没有这回事了。hooks.ts 的 Stop 分支现在直接返回 {},注释写的是永远不要把未读的 hive 邮件在 Stop 处变成一次强制续跑,那条老路绕过了终端草稿和 HITL 安全,并且可能在用户正在回答一个问题的时候花掉额度。改成 inbox 文件留在磁盘上,由渲染进程那条只在 idle 时投递的受控路径稍后唤醒,也就是上面那五个条件的门。
这个改动本身是对的,理由比原设计成熟,原设计里 Stop 钩子这条自治通道恰好绕过了它自己后来花最多心思建的那道门。但 HIVE.md 一个字没改。drainForStop() 这个函数还在 hive.ts:1029,全仓库搜了一遍,生产路径上没有任何调用者,只有两处注释提到它和一个测试文件在调它。
数字倒是可信的。抽查了 FLUSH_COOLDOWN_MS = 4500、STALE_INPUT_MS = 1_800_000、BOOT_GRACE_MS = 35_000、ECHO_GRACE_MS = 1000,和文档逐一对得上。所以读这个仓库的顺序大概是 docs/message-queue.md 和代码注释可信,HIVE.md 当路线图看,SPEC.md 当考古看。
许可证
代码是 MIT,但捆进来的像素美术,tileset、地图、《办公室》全体角色的底图,来自 LimeZu 的 FREE VERSION 授权,仅限非商业使用,重上色的精灵图继承这个限制。要商用必须换掉资源或者买授权,README 把这条写在了 IMPORTANT 框里。
其他
blog/ 目录下有 130 篇文章,标题是 claude-squad-vs-munder-difflin、best-ai-coding-agents 这类,每篇配一张手绘风格的 hero 图,是个挺直白的 SEO 内容矩阵,跟工程质量无关,看仓库的时候知道一下自己在看什么。
想抄走的其实就一条,判断该往哪边偏是按判错的两个方向哪个更贵来定的,读屏只能清除草稿不能发明草稿、回显间隙内返回不知道,都是这个。大部分项目在这些位置会写一个 if (isIdle) sendKeys(...) 然后开始收 issue。文件系统当协调层那套也挺省事,每个 agent 只写自己目录加一个进程负责搬运和提交,原子 rename 投递、per-agent 游标幂等、hop cap 防乒乓,三样加起来不到两百行,比引一个消息队列轻得多。
至于那些走来走去的小人,它自己的 spec 里写了句挺清醒的话,说这个隐喻的真实风险是落成噱头,缓解办法是让每个动画都真的告诉你一件你原本不知道的事,如果走到书架前没能比一个文字标签更快地传达它在读文件,那就是造了个玩具。