通过一个私有 git 仓库(vault)同步 AI 编程会话,配合 --resume 命令在换设备后无缝继续工作,避免会话困在单一机器。
我同时用三台笔记本电脑工作。Claude Code 和 Codex 都把每次对话存在启动它的那台机器上——这意味着那个终于让 AI 理解了代码库的会话,只能困在你上周二坐的那个地方。
Copilot 的解决方案是账户级云同步。但我不想让自己的代码对话日志存在厂商的云端,也不想跑一台服务器或为 S3 付费。我日常已经在用、信任且拥有的基础设施,是一个 git remote。于是我做了 repo-sessions:会话通过一个私有 git repo(一个"保险库")同步,同步触发器就搭在你已经在做的 git push 和 pull 之上。在笔记本 A 上 push,在笔记本 B 上 pull,然后 claude --resume,对话就在那里了。
这篇文章讲的是整个构建过程,主要通过三个 bug 来讲。每一个看起来都是小的边缘情况,最后都被证明是结构性的问题。
Claude Code 把每个会话存为 JSONL 文件,放在 ~/.claude/projects/<munged-path>/<session-uuid>.jsonl 下。munged path 是你项目的绝对路径,把所有 [A-Za-z0-9-] 之外的字符都替换成短横线:
export function mungeCurrent(absPath: string): string {
return absPath.replace(/[^A-Za-z0-9-]/g, '-');
}
所以 /Users/rishi/dev/proj 变成 -Users-rishi-dev-proj。在此基础上,转录文件的每一行都嵌入了你的 cwd、会话 id、工具版本和 git 分支。
两个后果。第一,resume 是通过你所在目录的 munged path 来查找会话的,所以在另一台机器上(不同的用户名、不同的克隆路径)文件在一个 Claude Code 永远算不出来的目录下。第二,即使你把文件放到了正确位置,嵌入的路径指向的也是那台机器上不存在的目录。
在写任何正式代码之前我通过实验验证了所有这些,因为这些都没有文档。方法是:启动一个建立了暗号的会话,手工把转录文件复制到第二个位置并重写路径,然后尝试 resume。对照组先跑:在"B 机"上不重写就 resume 结果是"No conversation found",这正是这个工具要修复的失败方式。重写后,resume 回来的会话回忆起了暗号,并报告了 B 机的路径作为它的 cwd。
这次探索还暴露了一个令人不舒服的事实:munge 规则在 Claude Code 各版本之间已经漂移了。我这台机器上同一个目录的转录文件同时存在于 -AI-AI-ML 和 -AI-AI_ML 之下,说明某个旧版本保留了下滑线。所以定位器改为扫描所有已知的规则变体,而不是计算一个目录名然后信任它。当你在未文档化的格式上构建时,你就继承了它们的历史。
在 push 时,转录文件中的所有绝对路径都变成 token:${CSS_PROJECT_ROOT}、${CSS_HOME},以及 munged 后的 dirname(当工具输出涉及 ~/.claude/projects/ 时就会出现)。在 pull 时,token 会被重写为本地机器的路径。Codex 需要第三种路径表面,因为它的 rollout 文件包含对 $CODEX_HOME 的自引用。
这个保险库刻意做得很无聊:一个私有 git repo,普通目录,每个项目一个命名空间,以规范化后的 origin URL 的哈希为 key。任何 git UI 都能检查它。同步触发器是 git hooks(pre-push 发出会话,post-merge 和 post-checkout 接收它们),已有的 hooks 会被链式加载,stdin 和 argv 保持完整,所以你的 husky 配置继续工作,它的失败仍然会中止 git 操作。我的从来不会:如果保险库不可达,一切表现得就像这个工具根本不存在一样。
在实际的 Windows 笔记本上 dogfooding 时发现的症状:一个同步过来的会话能打开,但只有最后几轮对话渲染出来了。
原因:转录文件是 JSON,而我在往原始字节里做路径替换。把 C:\Users\rishi\dev\proj 拼接到 JSON 字符串字面量里,你就写出了 \U,这不是一个合法的转义序列:
{"command":"ls C:\Users\rishi\dev\proj"} <- 无法解析的行
{"command":"ls C:\\Users\\rishi\\dev\proj"} <- 应该在磁盘上的样子
Claude Code 解析不了这些损坏的行,连接各轮对话的 parent-uuid 链断裂了,UI 只显示那些幸存下来的内容。所以现在所有转录文件的替换都工作在 JSON 转义后的拼写上:tokenizer 匹配路径的每种拼写形式(转义的、正斜杠的、任意大小写驱动器的、最长的优先,这样转义形式会在较短的之前被消费掉),重hydration 时发出转义形式。
同一家,同一周:Windows 的 git 的 autocrlf 把 LF 的保险库文件在 checkout 时改成了 CRLF,导致同步的记忆文件里出现了幻内容变更(修复:vault 里加 * -text 加上 EOL 不敏感的对比)。还有 git 默认的 1 MiB http.postBuffer 在通过 https push 超过几兆字节的转录文件时回以一个不透明的 HTTP 400,导致后台 push 静默失败——对于一个同步工具来说,这是最糟糕的失败模式。
目前的教训是:同一份内容在跨机器时比你想象的拥有更多字节表示形式。我以为我已经学会了。下一个 bug 是带着牙的同一课。
一个月后,一个会话在每次 pull 时都被判定为永久分歧,两台机器都是,即使我知道什么都没变。不用说,那正是我用 Claude 调试这个工具的会话。
机制是这样的。Tokenization 是机器相对的:我的 Mac 的 tokenizer 认识 Mac 的路径。但这份额 transcript 把我的 Windows 机器的路径拼写作为普通消息文本引用了——粘贴的终端输出、像 C--Users-rishi-... 这样的 munged dirname、一段元数据文件。当 Mac push 时,这些字符串原封不动地进入了 vault,因为对 Mac 来说它们不是路径,只是文本。Windows 机器的 tokenizer 本可以折叠它们。所以没有任何 Mac 能执行的 tokenization 能复现 vault 的字节内容,哈希永远对不上,同步层得出结论:会话已经分歧了。
然后事情变得更糟了。reconcile 命令在字节级别找两份副本的公共前缀。第一个引用拼写出现在第 62 行左右,所以它在那里找到了一个假的分叉点,并把之后的所有内容作为"分歧尾"拼接回去。每次修复命令运行都会让转录文件膨胀:471 行,然后 881 行,然后 1701 行,有些对话轮次被重复了八次。看着一个修复命令把病人越治越糟,是一种特殊的体验。
修复分两部分。每次 push 现在都在 vault index 里记录那个设备的路径上下文(项目根目录、home、工具数据目录)。比较操作不再碰原始字节:两端都在 vault 见过的每种设备上下文下通过 tokenizer 做折叠,按确定性顺序。
export function canonForCompare(adapter: Adapter, content: string, ctxs: PathCtx[], opts?: SubstOpts): string {
let out = adapter.stripVolatile ? adapter.stripVolatile(content) : content;
for (const c of ctxs) out = adapter.tokenize(out, c, opts);
return out;
}
Token 在进一步 tokenization 下是惰性的,所以这个折叠是一个投影,执行两次等于执行一次。这个属性是一条来自事故现场的单元测试:
const once = canonForCompare(claudeAdapter, fromA, ctxs, { json: true });
const twice = canonForCompare(claudeAdapter, once, ctxs, { json: true });
expect(once).not.toBe(fromA); // 折叠确实折叠了外来拼写
expect(twice).toBe(once); // 并且它是一个投影
同样重要的是:正规形式只是用于比较。Vault 始终存储 writer 自己的 tokenization,安装时始终重 hydration 原始存储字节。比较逻辑可以独立演进,而无需重写任何人的数据。
几周后,虚假的分歧又回来了,这一次转录文件里根本没有引用任何外来路径。
原来 Claude Code 大约每轮对话就在当前活着的机器上追加一条 title-refresh 记录({"type":"ai-title",...})。同一对话的两份诚实副本最终会拥有不同数量、不同位置的这类记录。从内容上说,一份是另一份的干净前缀,只是落后了一点。从字节上说,分歧了。
所以正规形式多了第二条轴:在路径折叠之前,adapter 可以剥离工具非确定性重写的记录。剥离是解析确认的,只丢弃顶级 JSON type 为 ai-title 的行,所以把这样一条记录作为数据引用的对话轮次会保留下来。这立刻就派上了用场,因为那个调试会话不断引用它们。
一个更普遍的教训比任何一个 bug 都更沉重:比较正规形式必须规范化每一条良性非确定性的轴,而且厂商每次发版推出的新记录类型都可能重新引入这个 bug 类。当你在未文档化的格式上构建时,你就在这个跑步机上。我的妥协是一个 doctor 命令,通过唯一不会说谎的方式验证 drift:实际 resume 一个会话并检查工具的响应。
三条原则完整地走过了这一切,现在成为每个功能的门槛:
Transcript 是只追加的。Prefix 意味着快进,分歧意味着冲突副本,chat rebase 把尾部的尾巴拼接回一个转录文件("与此同时,在另一台笔记本上",这是真相)。没有任何东西会被静默丢弃。Rewind 是从 vault 历史的一个分支,从不原地截断,因为其他设备会把"丢失的"轮次直接 push 回来。被删除的会话从 vault 历史恢复,因为 vault 是 git,每个同步状态都是一次提交。
永远不要写另一个工具的数据库。Codex 在 rollout 文件旁边的 SQLite 里索引会话。我不碰它。把重写过的 rollout 放入日期树,首次 resume 时索引会自愈。稍微差一点的 UX(交互式选择器只在按 id resume 过一次后才列出同步的会话),但失败模式好得多。
降级到无形。Vault 不可达、工具未设置、repo 未启用:每个 hook 都安静退出,coding 工具的行为完全就像 repo-sessions 从未安装过一样。
npm i -g repo-sessions
chat setup # 每台机器一次:创建或复用你的私有 vault
chat init # 每个 repo 一次:从这里开始的 git push/pull 携带你的会话
然后正常干活。会话和每个项目的记忆跟着 repo 走。chat list、chat resume <name>、当你在两台机器上同时超前时用 chat rebase,当你删了不该删的东西时用 chat restore。
(二进制文件名叫 chat。macOS 附带了一个拨号时代的老 pppd 工具在 /usr/sbin/chat,在安装前打开的 shell 会愉快地运行那个版本,它会阻塞在 stdin 上,看起来完全就像"工具什么都不做"。hash -r。别问我怎么知道的。)
MIT 许可,TypeScript,零运行时依赖,61 个密封测试跑在 3-OS CI 矩阵上,每天在三台机器上 dogfooding,包括一个 400k token 的会话跨 OS 准确保留了 recap 恢复过来。一个真实的警告:转录文件包含你会话看到的一切。Vault 必须是私有的(setup 拒绝公开 GitHub vault),push 跑一个 gitleaks 风格的 secret 扫描,还有一个 per-session 的忽略文件,但你应该像对待 .env 一样对待 vault。
字节相等对于同步内容来说是错误的身份。你需要对每个 writer 的编码都成立的相等,而且你应该预期 writer 集合会增长。
如果同步内容可以把你自己的同步元数据反引用回来,它最终会的。任何关于这个工具本身的会话都是一个自引用的输入,而 dogfooding 保证这种会话必然存在。
在比较时规范化,从不静止规范化。只用于比较的正规形式可以自由演进,因为它从不重写存储的数据。
当没有文档时,一个暗号和一个阴性对照在一个晚上能搞定推测永远搞不定的事情。
Repo: https://github.com/firish/repo-sessions
如果你尝试了,我没找到的那些失败模式是我最想听到的。