简而言之

  • 我同时运行的 Claude Code 会话中位数是八个,同一个检出目录里最多十个。三十天里,118 个会话列出了其他会话,7 个发出了消息。
  • 一个会话需要了解的同伴信息,早就都在磁盘上了:实时会话注册表、每一条输入的提示词、每个会话写过的每个文件。
  • 三个钩子把它们读出来。会话启动时显示谁在场、上一个会话做了什么;每条提示词显示同伴往记忆里写了什么;当有同伴共用同一检出目录时,拒绝执行全索引 git 操作。
  • 「编辑前先通报」这条规则加载十分钟后,一个会话主动告诉另一个它即将改动什么。我会在十月底重新统计消息数。

我同时跑很多 Claude Code 会话。截至 9 月 21 日的三十天记录:297 个会话,一天中任意时刻活跃的中位数是八个,峰值十二个,同一检出目录里最多十个。一半的会话存活超过五小时,十分之一超过 39 小时。

它们具备协作所需的全部能力。ListAgents 会列出机器上其他活跃会话;SendMessage 可以按名字联系任意一个。在那三十天里,118 个会话调用了 ListAgents,7 个发出了消息。它们缺的是说话的理由和可据以行动的事实,而这两样早就在磁盘上了。

问题:一个检出目录,八个会话,七条消息

一个检出目录上有八个会话,挂着三条带:git 索引与工作区,每个会话都在实时读写;记忆目录,每个会话在启动时读一次;注册表、记录和历史,每个会话每一轮都在写,却没人读 前两条带是会话共享的东西。第三条是所有会话都在写、而在此之前没人读的东西。

同一检出目录里的会话共享两样东西。git 索引与工作区:全树操作(git add -A、commit -a、不带路径的 stash)会作用于所有人的改动,而 git 根本不知道哪个文件属于哪个会话。还有按仓库归类的自动记忆目录,它在会话启动时加载一次:一个从 08:00 就开着的会话,永远看不到同伴在中午写下的更正。其余一切都是私有的。从外部看,一个同伴只是 life-os-a9 这样的名字加一个状态词。

随之而来的是三类故障,每一类都有实测:

  • 工作被吞掉。 9 月 1 日,一个会话暂存了六个文件;几秒后另一个会话跑了一条宽泛的 git add,把它们卷进了自己的提交。第一个会话唯一的症状,是 git commit 打印出「no changes added to commit」。
  • 规则被重新学习。 mistboard 的记忆里重新学到了三条规则,而这三条早就以不同的措辞存在于 life-os 的记忆中。
  • 盲目启动。 那个月有 302 个会话结束,2 个写了交接说明。每个新会话都是从零开始。

思路:读取进程本就在写的东西

Claude Code 维护着三类文件,却没人在读。~/.claude/sessions/<pid>.json 保存每个活跃会话的 id、工作目录、名字、状态和启动时间。~/.claude/history.jsonl 保存每一条输入的提示词及其会话 id。每个会话的记录保存了所有工具调用,包括它写过的每个文件的路径。同伴想知道的一切都在这里,而且由进程自己维护。

所以每个部件的规则是:读磁盘上已有的东西,绝不新增任何需要会话自己去保持更新的字段。手写交接说明的写入率是 1%;一个状态字段不会更好。记录里的第一条提示词就是一份目的声明,会话不可能忘记写它。

在实现过程中又冒出三条规则:

  • 把每个部件挂在其事实最新鲜的时刻:会话启动、每条提示词,或某条命令执行之前。
  • 拒绝,而不是加锁。一个能说出同伴是谁、替代方案是什么的守卫,胜过一把谁都看不见的锁。
  • 把指令放在决策发生的地方。写在会话根本不会加载的文件里的规则,默认会被违反。

方案:三个钩子上的四个部件

一个会话的时间线:会话启动时,同伴清单读取注册表、历史和记录,同时加载规则;每条提示词时,记忆增量读取记忆文件的修改时间和本会话的记录;每条 shell 命令前,git 守卫读取注册表和 git 顶层目录 每个部件都挂在其事实最新鲜的钩子上,读取的是第一张图里的那些文件。

所有接线就是 ~/.claude/settings.json 里的一段配置。钩子的标准输出会进入会话的上下文;退出码 2 会拒绝该工具调用,并把标准错误作为理由。脚本、测试和测量都在 claude-code-peers 里;下面摘出的是要紧的部分。

{
  "hooks": {
    "SessionStart": [{
      "matcher": "startup|resume",
      "hooks": [{ "type": "command", "timeout": 10,
                  "command": "python3 scripts/peers.py --hook" }]
    }],
    "UserPromptSubmit": [{
      "hooks": [{ "type": "command", "timeout": 5,
                  "command": "python3 scripts/memory_delta.py" }]
    }],
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{ "type": "command", "timeout": 5,
                  "command": "python3 scripts/git_index_guard.py" }]
    }]
  }
}

会话启动:上一个在这里的是谁,现在谁在场。 peers.py --hook 打印在这个检出目录里最近结束的那个会话(何时结束、运行多久、在做什么、改过什么),然后是活跃的同伴。第一段就是那份没人写的交接说明;手写的 HANDOFF.md 存在时会被指出来,比它应描述的会话还旧时会被标注。意图来自提示词历史,剔除了确认性的应答:一到四个词的提示词占了那个月的 35%,所以下限设为五个词。对每个同伴,会显示第一条实质性提示词、倒数第二条和最后一条,因为意图会漂移,而最后两条能体现漂移。它正在编辑的文件来自记录,从后往前读最近的 Write 和 Edit 调用。

- mistboard-08  busy · up 36h · 161 prompts · last 1m ago
    opened:  "can you investigate this game: https://mistboard…"
    before:  "this widget is still the old style, we gotta use…"
    now:     "let's also frame it as they won it, since the…"
    editing: apps/web/src/jungle-replay-board.ts (1m ago)

每条提示词:自你启动以来,同伴往记忆里写了什么。 memory_delta.py 检查记忆目录,列出在本会话启动之后被修改的文件,每个只列一次,并附上写入者的名字。自己写的会被排除,办法是在记录里检查是否有对该路径的写入。约 40 毫秒,且在大多数提示词上保持静默。

threshold = max(session_start, last_run)  # last_run: a stamp file
for f in memory_dir.glob("*.md"):
    if f.name == "MEMORY.md" or f.stat().st_mtime <= threshold:
        continue
    desc, origin = frontmatter(f)  # description:, originSessionId:
    if origin == session_id or written_here(f, transcript):
        continue                           # this session's own write
    print(f"- {f.name} ({when(f)}, {peer_name(origin)}): {desc}")

每条 shell 命令:有同伴共用检出目录时,禁止全索引 git 操作。 git_index_guard.py 解析命令中的 git add -A、add .、commit -a 和不带路径的 stash,解析出该命令实际触及的检出目录(包括 cd X && 和 git -C X),并读取注册表,查找同一 git 顶层目录上的其他活跃会话。工作树有各自的索引,从不计入。若检出目录里只有你一个,命令原样执行。

def peers_in(top, own_session_id):
    """Other live sessions whose checkout is this git toplevel."""
    return [j["name"] for j in live_sessions()
            if j["sessionId"] != own_session_id
            and toplevel(j["cwd"]) == top]   # a worktree has its own

peers = peers_in(toplevel(git_cwd), payload["session_id"])
if peers:
    sys.stderr.write(
        f"Refused: `{what}` while {len(peers)} other live session(s) "
        f"share this checkout ({', '.join(peers)}). The git index is "
        "shared; name the paths this session changed instead.\n")
    sys.exit(2)

每个会话、每个仓库:那些反复被重新学习的规则。 ~/.claude/rules/ 里的六个 markdown 文件,通过符号链接指向一个受版本管理的目录,会加载进这台机器上的每个会话:共享工作树的行为准则、token 预算、哪些账号可以在哪里发布、在声称「没人做过这件事」之前该核查什么。每条都指明了存放其证据的记忆文件。每次会话启动约两千 token,且有缓存。

每个部件都有一个夹具测试,按 Claude Code 的方式通过标准输入驱动它,另外还有一项接线检查,断言每个预期的钩子都已注册、存在、可编译并通过自己的测试。

目前的结果

接线是在傍晚落地的。第二天早上,life-os 里有两个同伴在场时,git add -A 被拒绝,并点出了这两个同伴。一个新会话对「现在什么情况」的第一次回答,开头就是三个同伴以及各自在做什么,接着是最近结束的那个会话,而我什么都没粘贴进去。而记忆钩子在一个活跃会话里的第一行是错的:一个几周前由同伴创建、刚被我编辑过的文件,被显示成了同伴的,因为 originSessionId 标识的是创建者,不是最后的编辑者。现在,对每个不是本会话创建的文件都会跑一次记录检查。

真正要紧的那条消息,出现在一段文字进入每个会话都会加载的 CLAUDE.md 之后的十分钟:在编辑同一检出目录中某个同伴的提示词提到的文件之前,或在同伴正忙时提交之前,先告诉它。这条在 00:08 落地。本博客仓库里的一个会话在 00:18 启动。大约 00:25,它主动向 life-os 会话发出了这条消息(文件列表有删减):

来自 brianhliou.com 会话的提醒:我正在 life-os 里做重命名清理:scripts/lesson_harvester.py、data/lessons.jsonl(去重 173 条重复记录)、CLAUDE.md:151、两份简报、通过 CLI 处理的两条笔记,然后执行 lesson_compile.py –apply。按文件名暂存;不会碰你的文件。

这条通报抵达 life-os 会话,以及它发出的一行回复 接收方会话的视角:消息抵达时的样子,以及它发出的回复。

回复只有一行:没有重叠,动手吧,外加一个它本不可能知道的事实:lesson_compile.py 一小时前在 main 上有过改动。这次工作以一次提交落地,触及七个文件,没有一个属于接收方。总共三条消息。这次交流中没有任何新能力;在此前十分钟里改变的是:规则被加载到了决策发生的地方,会话能看到同伴在编辑什么,而一条宽泛的 add 无论如何都会被拒绝。

按改变程度排序:交接说明第一,因为过去每个会话都是盲目启动;守卫第二,因为它是唯一预防损害而不只是描述损害的部件;消息最后,因为它是你唯一看得见的那个。

下一步:一个月后的数字

局限是清楚的。守卫只拒绝,不加锁,所以同伴的 git reset --hard 依然会回退你未提交的编辑,而关于这一点的规则只是一段文字。记忆钩子覆盖记忆目录,不覆盖 CLAUDE.md,所以会话进行中被改动的规则,只有在压缩上下文时才会传达给正在运行的会话。注册表和记录都是本地的,所以云端会话对这一切都是不可见的。

这些本来都不该由我来做。注册表、记录、历史文件、钩子和消息机制,全都随 Claude Code 一起发布。没有随之发布的,是把它们连起来的默认行为,所以一台机器上的八个会话,在有人去测量并接线之前,表现得就像八个陌生人。我预计这样的默认行为会出现在产品里;aide 最初是我自己在同样的记录之上搭的 SQLite,后来产品在同样的日志之上长出了 /insights。在那之前,接线就是一段设置配置加三个脚本,而你手上已有的那些记录,就是起点。

待检验的主张是:展示意图会让会话开口说话。基线是三十天里有七个会话发出过消息。我会在十月底重新测量,并带着数字回到这篇文章。如果数字没动,那名册从来就不是瓶颈,接下来该看的是:什么才是。