agent-sync:把没聊完的Agent会话装进Git,换台电脑接着聊
灿若繁星先生

下午在办公室的 Windows 上,一个会话聊到关键处:背景交代完了,报错日志贴过了,方案刚改到第三版。到家打开 Mac 想接着聊,会话列表里什么都没有。要么凭记忆把上下文重新喂一遍,要么把活儿留到明天。会话就是上下文本身,它留在另一台机器上,前面花的功夫等于白费。

这种事多来几次之后,我写了 agent-sync:一个在两台电脑之间搬运编码 Agent 会话的命令行工具。会话导出成加密文件,放进项目目录下的 .agent_sync/,跟着 git push 走;另一台机器 pull 下来,一条命令导回本机,接着聊。

先说笨办法为什么不行

最直接的想法是把 Agent 的本地会话库整个拷一份,U 盘、网盘、随便什么通道。试过就知道行不通。

第一关是路径。会话库按项目目录归属会话,project_id 由工作目录派生:办公室里项目在 C:\dev\my-app,家里在 /Users/<用户名>/dev/my-app,库文件直接拷过去,会话挂在一个对不上的项目名下,Agent 找不到它。

第二关是粒度。会话库是所有项目共享的一个文件,整库覆盖会把这台机器上新产生的会话一起冲掉。两台机器各自往前聊,每同步一次就互相销毁一次数据。

第三关是文件本身。会话库是 SQLite(WAL 模式),拿网盘做多端同步,基本等于随机损坏数据库。

结论:正确的粒度是单个会话,按会话导出、按会话导入,导入时把路径和项目归属按本机情况重写。剩下的只是传输通道问题,而写代码的人手里现成就有一条:项目自己的 git 仓库。

为什么不用现成方案

手工拷不行,那用现成的呢?这个痛点很普遍,市面上的方案大致两条路线,挨个看下来各有各的缺口。

一条是官方云同步。VS Code 的 Copilot 会话历史已经能跟着 GitHub 账号跨设备同步;Claude Code 官方做了跨会话消息,但「把会话搬过来继续聊」的原生同步,截至发文还是 open 的 feature request;Cursor 也还没有。就算以后都补齐,这条路线也只管自家生态:聊天记录存在厂商云上,跨工具共享指望不上。

另一条是社区工具。比如 agent-sessions-sync,一个 VS Code 扩展,同样走私有 GitHub 仓库,支持 Claude Code / Codex / Cursor 三家。思路接近,做法是把会话文件原样搬:没有加密;两边都续聊了只会标冲突让你二选一;路径差异要手动跑一条映射命令;而且只搬文件,碰不了 SQLite 这类数据库存储——那恰恰是我要解决的问题。

默认加密、按会话粒度进出数据库、跨系统路径重写、分叉续聊不丢消息,这四件事没有一件现成方案同时做到。那就自己写。

让会话搭 git 的顺风车

会话本来就是围着某个项目聊的,让它跟着这个项目的仓库走,语义刚好。.agent_sync/sessions/ 下一个 JSON 文件就是一次导出,push 上去,另一台机器 pull 下来导入。通道免费,自带版本历史,上上周导出的东西也随时翻得回来。

flowchart LR
    A["办公室 Windows
agent-sync export --push"] --> B["私有 Git 仓库
.agent_sync/sessions/ 密文"] B --> C["家里 Mac
git pull + agent-sync import"] C --> D["本机会话库
接着聊"]

落到命令上:

1
2
3
4
5
6
7
8
# 办公室:列出当前目录下的会话,找到要带走的那一个
agent-sync sessions

# 导出(默认加密),--push 顺带 git add + commit + push
agent-sync export sess_408aa --push

# 家里:pull 之后在同一个项目目录里导回本机
agent-sync import sess_408aa

找会话的 sessions 默认只列当前目录的,-g 看所有项目,--archived 翻已归档的。session id 很长,工具支持唯一前缀匹配,敲 sess_408aa 这种短前缀就行;命中多个会列出候选让你补。

导出时可以加别名。同一个会话允许导出多次,用不同别名区分,比如大重构之前打一个快照:

1
2
3
agent-sync export sess_408aa --alias before-refactor --push
# 回家后
agent-sync import before-refactor

导入不满意可以撤销:rollback 先列出这个会话在各张表里的行数,确认后逐表删干净。目录里攒了一堆会话文件时,import --all 一次全部导进来,整批只问一次口令。

加密:仓库里只能看到密文

会话文件的明文是完整聊天记录:需求讨论、代码、命令输出,什么都有。所以加密是默认行为,明文导出反而要显式指定并二次确认。

文件分两层。外层信封明文,只放定位用的元数据:session id、别名、导出时间、来源目录、格式版本。内层是会话内容整体加密后的密文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"format": "agent-sync",
"formatVersion": 1,
"sessionId": "sess_9f2c…",
"alias": "before-refactor",
"exportedAt": "2026-10-01T12:03:41.036Z",
"sourceDirectory": "C:\\code\\my-app",
"encrypted": true,
"alg": "A256GCM",
"kdf": "argon2id",
"kdfParams": { "m": 65536, "t": 3, "p": 1 },
"salt": "…",
"nonce": "…",
"ciphertext": "标题、消息、工具输出全部在这里"
}

加密用 AES-256-GCM,密钥从口令经 Argon2id 派生(m=64MiB、t=3)。AEAD 的好处是口令错了或文件被篡改,解密当场失败,不会解出一堆乱码假装成功。内存参数开到 64MiB 是为了抬高离线爆破的成本:就算仓库意外泄露,攻击者每试一个口令都要实打实烧掉 64MB 内存跑三轮迭代,慢到不值得。

口令只存在脑子里,不落盘、不进配置文件,两台机器靠你记住同一句话。要写进脚本时也可以走环境变量注入,省掉交互输入,代价是得提防 shell history 把口令记下来。忘记口令的后果也可控:本机数据完好,换个口令重新导一遍就行,丢的是同步通道,不是数据。

两条红线。一,必须推送到私有仓库,就算内容加密,信封上的 session id 和时间戳也是元信息泄露。二,明文导出只该在私有仓库调试时用,git 历史会永久留下明文,事后切加密也抹不掉,只能重写历史。

导入比导出麻烦得多

导出是只读操作,SELECT 出来打包加密就完事。导入要把会话在另一台机器上安顿好,细节全在这里。

路径重写是第一件。会话记录里的目录字段、消息里的 path.cwd、path.root,都要从来源机器的前缀改写成目标机器的;消息正文里出现的历史路径不动,那是聊天记录,改了反而失真。

第二件是项目归属。project_id 按导入时的本机目录重新派生,会话才能挂到这台机器上正确的项目名下,出现在会话列表里。

第三件是合并语义。两台机器可能都续聊过同一个会话,互导之后怎么办?按消息 id 做 union:行 id 全局唯一,导入用 upsert,两边消息都保留,谁也不覆盖谁。代价是不保证严格时序,排序可能有小幅交错,对「接着聊」这个目标够用。

第四件是 schema 门禁。会话库自带 migration 版本,导入前对比两边 schema 版本,不一致就警告,强制导入可能得到打不开的会话,由 rollback 兜底。整个导入包在一个事务里,要么全部写入,要么一点不动。

adapter:把 Agent 的差异关进接口里

上面所有跟具体 Agent 绑定的知识——存储在哪、表结构什么样、project_id 怎么派生——都隔离在一个 adapter 里。加密、信封、别名、前缀匹配、git 集成,核心代码与 Agent 无关:

1
2
3
4
5
6
7
8
9
10
11
interface AgentAdapter {
readonly name: string;
resolveContext(cfg): AdapterContext; // 各自解析存储配置
listSessions(ctx, limit, filter): SessionSummary[]; // 列会话
resolveSessionId(ctx, prefix): string; // 前缀补全
exportSession(ctx, sessionId): ExportedPayload; // 读本机库
checkSchemaCompat(ctx, payload): SchemaCompat; // schema 门禁
importSession(ctx, payload, dir, force): ImportResult; // 写本机库,单事务
rollbackStats(ctx, sessionId): Record<string, number>; // 回滚预览
rollback(ctx, sessionId): Record<string, number>; // 回滚
}

接入一个新的编码 Agent,成本是实现这个接口——存储路径的默认值和配置解析都住在 adapter 自己的模块里——再在 CLI 注册一行。用户侧零学习成本,所有命令本来就带 -a 参数选 Agent。

目前的支持情况:

  • ZCode:已支持。SQLite 型 adapter 的完整实现,导出、导入、合并、回滚整条链路先在它身上跑通。SQLite 型的存储最复杂,先啃硬骨头,后面的文件型反而简单。
  • Claude Code:计划中,下一个接入。
  • Codex:计划中,排在 Claude Code 之后。

把这三行填满,就是接下来要做的事。

后续规划

多 Agent 支持立项时先拆了三件地基,如今都已落地。会话文件顶层带上了 agent 字段(agent 类型原本只存在加密 payload 里,混放多种 Agent 文件的目录没法批量分发),import 按它自动派发 adapter,-a 参数退化为校验,类型不匹配直接报错;每个模块配了脱敏 fixture 的测试套件,三十九个用例,CI 门禁从构建升级为 npm test,矩阵里还加进了 Windows——README 宣称的 Windows 路径支持第一次进了持续验证;顺手做的还有 import --all 整批只问一次口令、导入按列形状缓存 prepared statement,大会话导入明显更快。

地基之下还挖出一个值得记一笔的坑:真实会话库的 migration 表里根本没有 version 列,按列名猜版本的旧逻辑会把 app_version(内容是工具版本号 "0.2.0" 这种)误读成 NaN,于是两台机器 schema 明明一致,导入也必然误报不匹配。修复改为解析迁移 id 的数字前缀(0022_xxx → 22),并且配了按真实表形状做的 fixture,把坑钉死在测试里。

接下来才是正菜:新 Agent 接入。两款候选的存储都调研过了:

  • Claude Code:~/.claude/projects/<目录slug>/<sessionId>.jsonl,一个会话一个 JSONL 文件,项目归属靠目录 slug,导入时按目标目录重算。
  • Codex:~/.codex/sessions/<日期>/rollout-*.jsonl,也是 JSONL,按日期分目录,工作目录记在文件头部。

文件型 adapter 比 SQLite 型省事得多:导出是读文件,导入是写文件,回滚是删文件,schema 兼容检查退化为格式版本字段。顺序上 Claude Code 先行,它格式公开、受众最大,适合当文件型范本;Codex 复用范本,剩下的主要是格式解析差异。

真正要费脑子的只有一个决定:JSONL 的合并语义。ZCode 按行 id 做 union,两台机器分叉续聊互导后消息都保留;JSONL 如果整文件覆盖,后导入的一端会把另一端的续聊整个冲掉,和「union 合并」的承诺不符。结论是按消息 id 做行级 union,只有当某个 Agent 的消息行取不到稳定 id 时才降级为整文件覆盖,并在文档里如实标注。每款新 adapter 落地前,还要跑通「本机导出 → 临时目录导入 → 回滚」全链路(用 fixture,不动真实库)。

更远处还有一件事:子代理产生的文件(中间产物、大文件)目前不同步,v1 只覆盖会话本身,等核心稳定后再考虑要不要带上。

几个工程上的取舍

  • Node 22.5+ 的内置 node:sqlite 直连 Agent 本地库,运行时依赖只剩 commander 和 @noble/hashes(纯 JS 实现的 Argon2id),全程没有原生模块要编译。对一个要在 Windows 上装的工具,这条比听起来重要,CI 矩阵里的 windows-latest 每次提交都在替你验证。
  • 全链路 fail fast:口令错了 GCM 校验当场失败,schema 版本对不上默认拒绝导入,id 前缀命中多个会话时列出候选让你补而不是猜一个。同步工具最怕「看起来成功了」。
  • 每次导入包在一个事务里,回滚前先展示各表行数让你确认。搬的是聊天记录,宁可流程繁琐一点。

把说明书也递给 Agent

这个工具的日常用户是人,但还有一类用户是 Agent 本身:让编码 Agent 替你跑同步时,它得知道口令怎么给、确认提示怎么跳,否则会在交互问句前干等到超时。仓库因此附带了一份面向 agent 的 skill,把非交互运行的三件事写明白:口令经 AGENTSYNC_PASSPHRASE 环境变量注入、schema 警告加 --schema-force、回滚加 -y。README 里有一段现成的提示词,发给手边的编码 Agent,它就会把 skill 装进用户级技能目录,此后「把办公室没聊完的会话同步过来」这种话,可以直接说给它听。

几条边界

  • 开源项目不要用。会话文件会进项目仓库,就算加密,也不该让贡献者 clone 到你的聊天记录。
  • 导入后会话没出现的话,重启一下 Agent,它可能有内存缓存。
  • 两台机器的 Agent 版本尽量一致,schema 对不上时导入会警告。
  • 口令强度是整个安全模型唯一的短板,用四五个随机单词,别用生日。
  • Agent 的数据不在默认位置时,改 ~/.agent-sync/config.json 里的路径即可,Windows 上按用户目录自动解析。

小结

  • 会话是上下文,上下文应该跟着项目走,私有 git 仓库是现成的通道。
  • 默认加密(Argon2id + AES-256-GCM),仓库里只有密文和定位元数据。
  • 导入侧做路径重写、project_id 重建、按消息 id union 合并,两边都续聊也不丢消息。
  • adapter 把 Agent 差异关在接口后面;ZCode 已支持,Claude Code、Codex 按计划排队。

工具已发到 npm(npm install -g @canyuda/agent-sync),要求 Node 22.5+。两台机器各装一份,会话从此跟着仓库走。