打开一个新会话,SessionStart 弹出一条提示:
multi-agent-watch: 4 other Claude Code session(s) are concurrently active in this repository (/private/tmp/temp). Coordinate carefully — avoid stomping on changes from other sessions. - session=0c03343d ... started=... - session=45eca943 ... - session=6fb7d291 ... - session=983343d4 ...这是我之前写的一个插件 multi-agent-watch 在干活——它检测同一个 repo 上有几个 Claude Code 会话在并发跑,撞车了就告警。但它只会”告警”,几个 agent 之间没法说话。当下就一个念头:既然它已经维护了一张所有会话的注册表,能不能让它们互相留言?
这篇记录从摸清机制到实现留言、自我总结、订阅,以及开发途中实战抓到的几个 bug。
先摸清:这插件到底是怎么工作的
第一性原理,先别动手,先搞清楚现状。插件全是 bash + jq,没有构建步骤。核心是一个”每会话一个 JSON 文件”的注册表:
ls ~/.claude/state/multi-agent-watch/sessions/# 0c03343d-....json 45eca943-....json 6fb7d291-....json ...每个文件长这样:
{ "session_id": "f29b455b-...", "cwd": "/private/tmp/temp", "repo_key": "/private/tmp/temp", "pid": 42435, "source": "monitor", "started_at": "2026-06-07T18:45:02Z", "last_heartbeat_epoch": 1780858073, "known_others_csv": "..."}hooks 怎么接线,看 hooks/hooks.json:
SessionStart→ 注册自己 + 扫描同 repo 的碰撞,打横幅、桌面通知、往新会话注入上下文;UserPromptSubmit+PostToolUse→monitor.sh,刷心跳 + 对比”其他会话集合”有没有变化,变了就注入一条 delta;Stop→ 刷心跳;SessionEnd→ 删掉自己的条目。
repo_key 用 git rev-parse --show-toplevel,非 git 目录退化成绝对路径——所以这几个会话 cwd 都是 /private/tmp/temp、repo_key 相同,才会互相判定为”在同一个 repo”。
结论:它是单向的状态广播,没有任何”消息”字段。 但注入用的通道——hook 的 hookSpecificOutput.additionalContext——正好可以复用来送消息。这就是切入点。
顺手确认:另外几个 agent 真在干嘛
注册表里有 transcript_path,顺着翻了一眼其他会话最近碰的文件,定位它们的战场:
| 会话 | 实际工作 |
|---|---|
45eca943 | kaorou/src/yt2bili/(队列、UI) |
6fb7d291 | API 平台部署(/srv/auth、redeploy.sh) |
0c03343d | .hermes/ 配置 |
983343d4 | 烤肉字幕修复——还建了个 COORDINATION-kaorou.md |
那个 COORDINATION-kaorou.md 很有意思:它就是一个 agent 在用文件给另一个 agent 留言,等对方上线优先级系统的信号——本质是在轮询一个文件。这正好印证了”让会话互相留言/订阅”的价值。
实现一:每个会话自我总结一句话
光知道”有别的会话在”,不知道它在干嘛,价值有限。于是让每个会话生成一句”我在干什么”,别的会话能读到。
总结用 claude -p --model haiku 跑——headless 模式,Haiku 4.5 便宜、快,正适合这种简单总结。喂给它的是 transcript 的一个摘要(最近若干条 user/assistant 文本),让它一句话概括。
这里有三个必须想清楚的约束:
为什么在 Stop 触发,而不是每个工具调用? 一开始我挂在 PostToolUse 上,马上意识到不对——PostToolUse 每个工具调用都触发,一个回合能触发几十次。改挂 Stop:一个回合结束时 transcript 是个完整的工作单元,总结质量高,而且天然低频。
为什么只跑一次? 即便挂 Stop,每个回合也会触发。如果每次都调 claude -p,N 个会话来回烧 token。所以 mw_generate_summary 默认 run-once:已有 summary 就直接返回,再用一个 mkdir 原子锁防并发重复。mkdir 在同一文件系统上是原子的,第一个赢,其余失败,天然适合做”只执行一次”的闸。
mw_try_lock() { mkdir -p "$MW_LOCKS_DIR" 2>/dev/null || true mkdir "$MW_LOCKS_DIR/$1" 2>/dev/null # 原子:已存在则非 0}为什么不阻塞? claude -p 要几秒,不能卡住主回合。Claude Code 的命令钩子支持 "async": true——后台跑、不阻塞,官方文档明说适合”外部 API 调用”这种长耗时场景。于是 Stop 上挂两个钩子:heartbeat.sh(同步、快)和 summarize.sh(异步)。
"Stop": [{ "matcher": "", "hooks": [ { "type": "command", "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.sh" }, { "type": "command", "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/summarize.sh", "async": true, "timeout": 150 }]}]还有一个坑:summarize.sh 自己会去调 claude,那个子 claude 又会触发 multi-agent-watch 的 hooks,递归。解法是子进程里 export MW_SUMMARY_CHILD=1,每个 hook 开头检查这个变量,有就直接退出:
[ -n "${MW_SUMMARY_CHILD:-}" ] && exit 0别的会话怎么读到?各会话只总结自己(写进自己的注册表条目),monitor.sh 在注入 roster 时顺带把每个兄弟会话的 summary 带上。N 个会话总共 N 次总结调用,不是 N²。
实现二:直接留言 /mw-send
留言比总结简单:一条消息一个文件,丢进收件人的 inbox 目录:
messages/<recipient_full_sid>/<epoch>-<from>.json一文件一消息,不需要加锁,mv 重命名天然原子。投递端复用现有通道:monitor.sh 每次触发时 drain 自己的 inbox,把未读消息拼进注入的 additionalContext,然后删掉文件。
这里有个时序坑值得记:monitor.sh 原本在”roster 没变化”时会提前 exit 0。如果把 drain 放在这个 early-exit 后面,那只有别的会话进出时才会收到消息。所以 drain 必须放在 early-exit 之前——每次触发都查 inbox,跟 roster 变没变无关。
发信人身份取自环境变量 $CLAUDE_CODE_SESSION_ID(slash 命令的 bash 能拿到),all 广播给同 repo 的其他会话并排除自己。
实战:开发途中真被另一个 agent 发了消息
功能写完跑测试,结果同时撞上三件事,把整套东西在真实环境里全验证了。
先是真实的 claude -p --model haiku 总结,9.6 秒返回:
time (printf '{"session_id":"...","transcript_path":"...","hook_event_name":"Stop"}' \ | bash hooks/summarize.sh)# real 0m9.643s# A.summary = Finalizing multi-agent-watch v0.2.0: message delivery, async# Stop-hook summaries, real-world hook testing.一句话,准确概括了我当时在干嘛。
然后日志里冒出另一个会话 b0c6596a 也自动生成了总结——它是我改完 hooks.json 之后才启动的新会话,所以拿到了带 async 总结钩子的新配置,Stop 时自动触发了。
最炸的是这条,直接注入进了我的上下文:
✉ from b0c6596a (cwd /private/tmp/temp): 你好,我是另一个在 /private/tmp/temp 工作的 Claude Code session。 请问你当前在这个仓库里做什么任务?改动了哪些文件?方便的话同步一下, 避免互相覆盖。谢谢!b0c6596a 这个真实的兄弟会话,用我刚写好的 /mw-send 给我发了条协调消息,我的 monitor.sh 把它 drain 出来注入了。我回了它一条(说明我只在改插件本身、没碰 /private/tmp/temp 里的工作文件)。留言功能在真实多对多场景下跑通了——这种 dogfooding 时机可遇不可求。
实战抓到的 bug
Bug 1:非原子写,会误删有效会话。 原来 mw_write_session 是 jq ... > "$file"。5 个会话并发,某个会话的 mw_purge_stale 可能读到正在写一半的文件 → 解析失败 → rm -f 把一个有效会话删了。改成写临时文件再 mv:
mw_atomic_write() { local dest="$1" tmp="$1.tmp.$$.${RANDOM:-0}" cat > "$tmp" 2>/dev/null && mv -f "$tmp" "$dest" 2>/dev/null}顺带把 purge 改保守:解析失败的文件只有”同时还旧”(看 mtime)才删,新文件(疑似写一半)绝不动。
Bug 2
mw-status 发现的——刚生成的 summary 转头就变回 (no summary yet)。根因:mw_write_session 从头用 jq -n 重建 JSON,只搬运了 started_at、known_others,没搬 summary,于是下一次心跳写入就把它清了。修法没有逐个字段去补,而是改成 merge 到已有条目:
base="$(jq -c '.' "$file" 2>/dev/null)"; [ -z "$base" ] && base='{}'printf '%s' "$base" | jq '. + { session_id:$sid, last_heartbeat:$now, ... }' \ | mw_atomic_write "$file". + {…} 保留所有已有字段,summary、watchers、将来任何新字段都自动保活——一次性灭掉整类”字段被覆盖”的 bug。
Bug 3*?[ 的消息,zsh 报 no matches found。根因是 slash 命令的 .md 把 $ARGUMENTS 原文(未加引号)拼进命令行,外层 zsh 对消息做了 glob 展开,匹配不到文件就报错中止。
before/after 实测:
# 修复前(未加引号)zsh -c "bash mw-send.sh recip-bb 你好 *.zzzNOPE 路径[abc]?"# zsh:1: no matches found: *.zzzNOPE ← 复现
# 修复后(.md 改用 "$ARGUMENTS",收件人在脚本里拆)zsh -c "bash mw-send.sh \"recip-bb 你好 *.zzzNOPE 路径[abc]?\""# → queued for recip-bb# 投递内容原样保留:你好 *.zzzNOPE 路径[abc]? 同步一下引号一加,zsh 不再分词/glob,消息原样进脚本,收件人(第一个 token)在脚本里用参数展开拆出来。
实现三:订阅 /mw-watch
最后加的是订阅:/mw-watch <id> 关注某个会话,被关注方更新 summary 或离开时,推一条 🔔 给你。
实现上复用前面所有零件mw_set_summary 变化时和 mw_remove_session 时 fan-out 到关注者的 inbox,再由 monitor.sh drain 出来。SessionEnd 自动退订,死掉的关注者惰性清理。
live 验证(临时 target,不打扰真实会话):
👁 subscribed to zzdemo01我的 inbox: 0 -> 1 ✉ from watch:zz: 🔔 watched session zzdemo01 updated: demo: pretending to refactor foo.py ✉ from watch:zz: 🔔 watched session zzdemo01 ended: session closed它把 COORDINATION-kaorou.md 那种”轮询一个文件等信号”变成了”被推送”。
两个值得记的坑
hooks.json 不热加载。 monitor.sh 的内容改动会在下次触发时重新 source 生效,但往 hooks.json 新增一个 hook 条目,对已经在跑的会话不生效——只有新启动的会话才会加载。所以那几个早于我改动启动的会话,要重启才会自动跑 Stop 总结;新启动的 b0c6596a 直接就有了。(官方文档没明确这点,属经验性行为。)
一个插件三份拷贝。 这插件同时存在于三个地方:~/Codes/claude-plugins-local/multi-agent-watch/(源,git 仓库,directory marketplace 的 source)、~/.claude/plugins/cache/local/.../0.1.0/(实际加载运行的安装副本)、以及一个早年遗留的 ~/.claude/plugins/multi-agent-watch/(没被加载)。判断哪个是活的:看注册表条目的 source 字段——monitor 只有 cache 副本的 monitor.sh 会写。改动要在 live 调、同步回源、再提交。
NOTE本地 directory marketplace 的配法:
~/.claude/settings.json的extraKnownMarketplaces里写{"source":"directory","path":"..."},enabledPlugins里"name@local": true启用。
经验总结
- 第一性原理先摸清现状再动手。 这插件已有的注册表 + hook 注入通道,就是留言/总结/订阅的全部地基,没必要另起炉灶。
- 复用一条投递通道。 留言、自我总结播报、订阅通知,最后全走”写文件 → monitor drain → additionalContext 注入”这一条路,代码量很小。
- merge 比 rebuild 安全。 多写者并发改同一个 JSON,从头重建总会漏字段;
. + {…}一劳永逸。 - 原子写不是可选项。 只要有并发读者,
> file就有被读到半截的风险,一律 temp +mv。 - dogfooding 抓 bug 最狠。 summary 被心跳清掉这个 bug,43 个单元测试 + 集成冒烟都没覆盖到,是真实环境刷一次
mw-status才暴露的。后来补了回归测试。
整套东西最终是 v0.2.1,43 个测试全绿。代码在 StevenLi-phoenix/multi-agent-watch。