2735 字
14 分钟
让并发的 Claude Code 会话互相留言、播报、订阅

打开一个新会话,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。

StevenLi-phoenix
/
multi-agent-watch
Waiting for api.github.com...
00K
0K
0K
Waiting...

先摸清:这插件到底是怎么工作的#

第一性原理,先别动手,先搞清楚现状。插件全是 bash + jq,没有构建步骤。核心是一个”每会话一个 JSON 文件”的注册表:

Terminal window
ls ~/.claude/state/multi-agent-watch/sessions/
# 0c03343d-....json 45eca943-....json 6fb7d291-....json ...

每个文件长这样:

sessions/<sid>.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 + PostToolUsemonitor.sh,刷心跳 + 对比”其他会话集合”有没有变化,变了就注入一条 delta;
  • Stop → 刷心跳;
  • SessionEnd → 删掉自己的条目。

repo_keygit rev-parse --show-toplevel,非 git 目录退化成绝对路径——所以这几个会话 cwd 都是 /private/tmp/temprepo_key 相同,才会互相判定为”在同一个 repo”。

结论:它是单向的状态广播,没有任何”消息”字段。 但注入用的通道——hook 的 hookSpecificOutput.additionalContext——正好可以复用来送消息。这就是切入点。

顺手确认:另外几个 agent 真在干嘛#

注册表里有 transcript_path,顺着翻了一眼其他会话最近碰的文件,定位它们的战场:

会话实际工作
45eca943kaorou/src/yt2bili/(队列、UI)
6fb7d291API 平台部署(/srv/authredeploy.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 在同一文件系统上是原子的,第一个赢,其余失败,天然适合做”只执行一次”的闸。

Terminal window
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(异步)。

hooks/hooks.json
"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 开头检查这个变量,有就直接退出:

Terminal window
[ -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 秒返回:

Terminal window
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_sessionjq ... > "$file"。5 个会话并发,某个会话的 mw_purge_stale 可能读到正在写一半的文件 → 解析失败 → rm -f 把一个有效会话删了。改成写临时文件再 mv:

Terminal window
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_atknown_others,没搬 summary,于是下一次心跳写入就把它清了。

修法没有逐个字段去补,而是改成 merge 到已有条目:

Terminal window
base="$(jq -c '.' "$file" 2>/dev/null)"; [ -z "$base" ] && base='{}'
printf '%s' "$base" | jq '. + { session_id:$sid, last_heartbeat:$now, ... }' \
| mw_atomic_write "$file"

. + {…} 保留所有已有字段,summarywatchers、将来任何新字段都自动保活——一次性灭掉整类”字段被覆盖”的 bug。

Bug 3 把中文/glob 消息吃了。 用户报的:通过 slash 命令发带中文或 *?[ 的消息,zsh 报 no matches found。根因是 slash 命令的 .md$ARGUMENTS 原文(未加引号)拼进命令行,外层 zsh 对消息做了 glob 展开,匹配不到文件就报错中止。

before/after 实测:

Terminal window
# 修复前(未加引号)
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 或离开时,推一条 🔔 给你。

实现上复用前面所有零件 列表存在被关注方的条目里(靠前面的 merge-write 自动保活),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.jsonextraKnownMarketplaces 里写 {"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

让并发的 Claude Code 会话互相留言、播报、订阅
https://blog.lishuyu.app/posts/multi-agent-watch-会话协调/
作者
猫猫魔女
发布于
2026-06-07
许可协议
CC BY-NC-SA 4.0