opencode 会话数据存在哪:本地存储层与同步层是怎么拆开的
本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。
在 opencode 里,“我的会话存在哪”这个问题没有单一答案:本地持久化和跨机器同步是两套彼此独立的机制,前者是一个 SQLite 库文件加一小撮遗留 JSON,后者是一份带序列号的事件日志外加几个 HTTP 端点。 你如果只盯着数据库文件,会以为换台机器把文件拷过去就行;你如果只盯着同步接口,又会以为这东西天然多端可写。两个判断都会让你在真实场景里翻车,而这两层的分界线,恰好也是隐私风险最集中的地方。
opencode 是一个跑在终端里的开源编码 Agent(MIT 许可证,仓库 https://github.com/anomalyco/opencode )。它会在你的机器上执行 shell 命令、直接改你的代码文件、把代码片段发给模型服务商——这三件事产生的全部痕迹,最后都沉淀在下面这几层里。
站内已经有几篇讲会话数据的文章:Hermes Agent 的状态层怎么设计 讲的是常驻 Agent 的运行时状态归属,pi 的会话存储 讲的是另一个项目怎么落盘一轮对话,Agent 记忆分层 讲的是记忆抽象本身该切几刀;本篇不重复这些,只死磕 opencode 这一个项目里”存储”与”同步”被拆成两块之后,各自承担了什么、又漏掉了什么。
一、本地这一层:一个数据库,加一小撮没走干净的 JSON
先看数据库文件落在哪。packages/core/src/database/database.ts 里的 path() 函数把这件事写得很直白:
export function path() {
if (Flag.OPENCODE_DB) {
if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return join(Global.Path.data, Flag.OPENCODE_DB)
}
if (
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return join(Global.Path.data, "opencode.db")
return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
}
这段代码里有三个对你有用的信息。第一,库文件默认叫 opencode.db,落在 Global.Path.data 下;packages/core/src/global.ts 里这个目录由 xdgData 拼上应用名得到,官方文档 packages/web/src/content/docs/troubleshooting.mdx 给出的说法是 macOS/Linux 在 ~/.local/share/opencode/,Windows 在 %USERPROFILE%\.local\share\opencode。第二,环境变量 OPENCODE_DB 可以把库指到别处,包括 :memory:——这是做测试和临时会话时最省事的开关。第三,非正式安装渠道会自动换成带渠道后缀的库文件名,所以你如果同时装了不同渠道的版本,看到的会话列表可能压根不是同一份数据,OPENCODE_DISABLE_CHANNEL_DB 就是用来强行合并回同一个库的。
连接建立之后紧跟着的是一串 pragma,同样在 database.ts 里:
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
yield* DatabaseMigration.apply(db)
journal_mode = WAL 意味着数据库在磁盘上不止一个文件,你想整体搬走就不能只拷主库文件;busy_timeout 说明它预期会有多个进程同时摸同一个库(CLI、服务端、桌面端各自跑着);foreign_keys = ON 则解释了为什么后面那些表能用级联删除来做数据清理。最后一行 DatabaseMigration.apply 把 packages/core/src/database/migration/ 下的迁移脚本按序跑一遍,那个目录里每个脚本的文件名都以年月日时分秒的时间戳打头,顺序由文件名决定,所以你看文件名就能读出表结构的演进次序。
表结构本身不在 packages/opencode 里。packages/opencode/src/storage/schema.ts 整个文件只有五行,全是再导出:
export { AccountTable, AccountStateTable, ControlAccountTable } from "@opencode-ai/core/account/sql"
export { ProjectTable } from "@opencode-ai/core/project/sql"
export { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql"
export { SessionShareTable } from "@opencode-ai/core/share/sql"
export { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
这五行值得多看两眼。它把”表定义归 core 包所有”这件事摆在了明面上——specs/storage/remove-opencode-db.md 的不变式清单里也专门写了一条,要求表定义留在 packages/core/src/**/*.sql.ts,不要搬回 packages/opencode。对你的意义是:你要查某张表到底有哪些列,别在 opencode 包里翻,直接去 core 包对应的 sql.ts。会话正文相关的四张表 SessionTable、MessageTable、PartTable、TodoTable 都在 packages/core/src/session/sql.ts,其中 SessionTable 除了标题、目录、项目归属,还带着 token 用量的几列和 share_url。
那另一半”没走干净的 JSON”是什么?packages/opencode/src/storage/storage.ts 里还留着一套完整的键值文件存储,接口就五个方法:
export interface Interface {
readonly remove: (key: string[]) => Effect.Effect<void, FSUtil.Error>
readonly read: <T>(key: string[]) => Effect.Effect<T, Error>
readonly update: <T>(key: string[], fn: (draft: T) => void) => Effect.Effect<T, Error>
readonly write: <T>(key: string[], content: T) => Effect.Effect<void, FSUtil.Error>
readonly list: (prefix: string[]) => Effect.Effect<string[][], FSUtil.Error>
}
键是一个字符串数组,落盘时被 path.join(dir, ...key) + ".json" 拼成文件路径,根目录是 Global.Path.data 下的 storage 子目录。并发安全靠的是按目标文件路径取一把可重入读写锁:读走读锁,写和更新走写锁,锁本身放在一个空闲即回收的映射里。这套东西不用数据库,就是一堆 JSON 文件加文件锁。
同一个文件里还有两个迁移步骤,读它们能反推出历史布局。第一步扫 project/ 下的每个目录,从 storage/session/message/*/*.json 里读出消息记录的根路径,再对那个工作树跑 git rev-list --max-parents=0 --all 列出全部根提交,把哈希字符串排序后取第一个当项目 ID(仓库有多个根提交时靠这个排序拿到稳定结果,而不是靠时间先后),然后把会话、消息、片段分别搬到 session/<项目ID>/、message/<会话ID>/、part/<消息ID>/ 下。第二步把会话里的 summary.diffs 拆出去单独存成 session_diff/<会话ID>.json,主记录只留增删行数的合计。迁移进度写在 storage 目录下一个叫 migration 的纯文本标记文件里,某一步失败就直接中断循环、不推进标记。
用工程视角总结这一层:主线数据已经在 SQLite 里,但磁盘上仍有一份按项目根提交哈希分目录的 JSON 遗留结构,你做备份、清理或者写外部工具时两边都得算进去。
二、同步这一层:单写者、序列号,以及一份留在原地的设计说明
packages/opencode/src/sync/README.md 是这个项目里少见的第一人称设计文档,它把同步的核心约束写在最前面:只允许一台设备写,其它设备通过拉取事件日志并在本地重放来跟上。因为只有一个写者,就不需要向量时钟或者因果序,直接用一个自增的序列号做全序。
它引入的抽象叫 SyncEvent,定义长这样:
const Created = SyncEvent.define({
type: "session.created",
version: 1,
aggregate: "sessionID",
schema: Schema.Struct({
sessionID: SessionID,
info: Info,
}),
})
比普通事件多出来的两样东西是 version 和 aggregate。aggregate 指明这条事件挂在哪个聚合上——会话相关的事件就挂 sessionID,序列号是按聚合各自递增的,不是全局一条流。文档里还讲了一个关键的顺序反转:事件溯源要求事件在变更发生之前发出,由”投影器”去执行真正的写入,这跟原来那套先改数据、再广播通知的顺序正好相反,而这个反转正是同步能成立的前提。为了不破坏既有订阅者,同步事件会自动再以总线事件的形态发布一次,两种事件形状的差异(一边是 id/seq/aggregateID/data,一边是 properties)由一层运行时转换和 busSchema 类型标注兜住。
这里有个必须如实说的情况:这份 README 描述的抽象,在当前代码里已经不在原处了。packages/opencode/src/sync/ 目录下现在只剩 README.md 和一个 schema.ts,后者只定义了一个以 evt 开头的事件 ID 类型。而 specs/storage/remove-opencode-db.md 里对应那一组的状态明确写着已完成,说明会话与消息的事件投影已经迁到了 core 包的事件与投影器基础设施上。所以你读这份 README 要当设计说明读,别当代码地图读——真正在跑的表结构在 packages/core/src/event/sql.ts:
export const EventSequenceTable = sqliteTable("event_sequence", {
aggregate_id: text().notNull().primaryKey(),
seq: integer().notNull(),
owner_id: text(),
})
event 表则带 id、aggregate_id、seq、type、data 五列,并在 (aggregate_id, seq) 上建了唯一索引。序列表多出来的 owner_id 是”谁是当前写者”的凭据,packages/core/src/event.ts 里的重放路径会拿它做归属校验,遇到写者不匹配或者同一序列号上内容不一致时直接报错而不是硬写覆盖。
跨机器那条路是 HTTP。packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts 定义了四个端点,路径就叫 /sync/start、/sync/replay、/sync/steal、/sync/history。history 的载荷设计得很聪明:请求体是一个”聚合 ID → 我已知的最后序列号”的映射,服务端只回超过该序列号的事件;没在映射里出现的聚合,直接返回全量历史。客户端那侧的循环在 packages/opencode/src/control-plane/workspace.ts,它的顺序值得留意:先把 SSE 长连接挂上去开始接新事件,连上之后才去补历史——补历史时按工作区 ID 查出本地有哪些会话,从序列表读出各自的进度拼成上面那个映射发过去,把返回的事件逐条重放。先连后补这个次序的好处不难推:如果反过来先补完历史再连,补历史那段时间里对端新产生的事件就没人接着,容易留下一个缺口。整段逻辑包在一个不退出的循环里,断线后按指数退避重连(重试间隔翻倍、上限两分钟),重连成功再补一次历史。
“换台机器继续之前那个会话”就落在这套机制上,而它的成本是:你必须先把写权限抢过来。 这就是 /sync/steal 存在的原因——它把某个会话的工作区归属改成当前工作区。单写者模型没有留下”两边同时写、事后合并”的位置,所以这个动作是抢占式的,不是协作式的。
三、一张表把两层对齐
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 键值 JSON 存储服务 | 按字符串数组键读写 JSON 文件,带按文件路径的读写锁 | packages/opencode/src/storage/storage.ts | 排查磁盘上那堆遗留 JSON、写外部清理脚本时 |
| 历史布局迁移 | 把旧的按项目目录组织的会话/消息/片段搬到新布局,进度写在 migration 标记文件 | packages/opencode/src/storage/storage.ts | 从老版本升级后发现目录结构变了、或迁移中断时 |
| 表定义再导出 | 把 core 包里的账号/项目/会话/分享/工作区表集中导出 | packages/opencode/src/storage/schema.ts | 想知道会话数据到底有哪些表时的第一站 |
| 数据库连接与路径 | 决定库文件位置、设置 pragma、跑迁移 | packages/core/src/database/database.ts | 想换库位置、查 WAL 行为、定位多渠道数据分裂时 |
| 迁移脚本目录 | 按时间戳顺序演进表结构 | packages/core/src/database/migration/ | 升级后表结构对不上、需要确认某列何时引入时 |
| 事件与序列表 | 存事件正文与每个聚合的当前序列号、写者归属 | packages/core/src/event/sql.ts | 排查同步落后、序列冲突时 |
| 事件读写与重放 | 分配序列号、按聚合读历史、校验重放是否分叉 | packages/core/src/event.ts | 同步报错找根因时 |
| 同步 HTTP 端点 | start/replay/steal/history 四个动作的契约 | packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts | 想知道两端到底交换了什么时 |
| 同步端点实现 | 落地重放、抢占归属、按进度返回增量历史 | .../httpapi/handlers/sync.ts | 同上,需要看实际行为时 |
| 工作区同步循环 | 拉历史、挂 SSE、断线重连、把事件重放进本地库 | packages/opencode/src/control-plane/workspace.ts | 多端一直不同步、连接状态异常时 |
| 事件溯源设计说明 | 解释单写者、聚合、序列号与向后兼容取舍 | packages/opencode/src/sync/README.md | 想理解”为什么这么设计”时 |
| 存储适配器规划 | 记录数据库适配器与去除旧包装层的迁移路线和不变式 | specs/storage/effect-sqlite-package.md、specs/storage/remove-opencode-db.md | 想判断某段代码是历史包袱还是当前主线时 |
| 数据库命令行入口 | 打印库路径、执行原始 SQL、开交互式 sqlite 会话 | packages/opencode/src/cli/cmd/db.ts | 手工核对数据时 |
| 导出与导入 | 把单个会话导出成 JSON、从文件或分享链接导入 | packages/opencode/src/cli/cmd/export.ts、import.ts | 搬迁、留档、提交问题复现材料时 |
顺带一提,packages/ 下一共 32 个包,其中 effect-drizzle-sqlite 和 effect-sqlite-node 是专门为这套存储抽出来的独立包。specs/storage/effect-sqlite-package.md 明确要求这个包保持通用——不放 opencode 的路径、迁移、表定义和领域语言,只做 Drizzle + Effect + SQLite 的适配层,项目相关的部分留在 packages/opencode。这种”通用适配包 + 领域包装层”的切法,是你在自己项目里可以直接借鉴的一条分界线。
四、边界与代价:这套设计放弃了什么
放弃了多端并发写。 单写者是全部设计的地基。序列号只是一个整数,靠”同一时刻只有一个人递增”来保证全序。一旦两端同时写,序列就会撞车,而重放路径的处理方式是拒绝并报错,不是合并。所以”两个人同时在同一个会话里跟 Agent 对话”这个场景,这套机制不打算支持;想换机器接着聊,正确姿势是把归属抢过来,而不是两边都开着。
放弃了跨设备的事务性一致。 同步是拉历史加重放,中间隔着 HTTP 和 SSE。断线期间的事件靠重连后补拉,补拉的粒度是”你告诉我你到哪了”。这意味着从另一台机器上看到的会话状态天然是滞后的,滞后多少取决于网络和重连节奏。任何依赖”两端此刻完全一致”的判断都不成立。
不管你的机器安全。 库文件就是一份明文 SQLite,同目录下还躺着 auth.json(文档里写明存着 API 密钥和 OAuth 令牌,代码里的位置是 packages/opencode/src/auth/index.ts)和 mcp-auth.json。会话表里存着完整的对话正文、工具调用的输入输出、文件片段和补丁内容——换句话说,这个库文件本身就是你私有代码的一份副本。谁能读你的用户目录,谁就拿到了这些。这层由操作系统的文件权限和你自己的磁盘加密负责,项目本身不提供额外保护。
不管你把什么发出去了。 Agent 干活时会把代码内容发给模型服务商,这一步的记录会落进库里,但库里有记录不等于外面没有副本。各家服务商的数据处理规则不同且会调整,以官方最新说明为准。
分享是另一条完全独立的外泄面。 SessionShareTable 只有会话 ID、分享 ID、密钥和 URL 四个字段加时间戳,说明分享是个轻量映射,真正的正文会同步到服务端。官方文档 packages/web/src/content/docs/share.mdx 说得很清楚:分享出去的对话对任何拿到链接的人都是公开可访问的,包含完整对话历史、全部消息与回复、会话元数据,并且一直保持可访问直到你显式取消分享。它同时给了三种模式的配置:默认的 manual 要手动执行 /share,auto 会把每个新会话自动分享,disabled 则完全关掉。
不管误删误改。 存储层记录的是发生过什么,不是”要不要发生”。Agent 在你机器上跑 shell、改文件的权限边界不在这一层,把权限放太松的后果(覆盖未提交的改动、删掉不该删的目录)也不是靠翻会话历史能挽回的——历史只能告诉你它当时干了什么。
五、上手与避坑清单
别拿单个 .db 文件当完整备份。 会踩是因为看见 opencode db path 打印出一个路径就以为拷它一个就够了。实际上开了 WAL 的 SQLite 在磁盘上不止一个文件,另外 storage 目录下还有那套遗留 JSON。避法:备份时整个数据目录一起打包,或者用 sqlite 自己的备份机制导出一致快照,别在进程运行时裸拷主库文件。
升级完发现会话列表空了,先怀疑渠道后缀。 会踩是因为 path() 里对非正式渠道会拼出带渠道名的库文件,你换了个安装渠道就等于换了个库。避法:先跑 opencode db path 看当前指向哪个文件,确认是不是跟之前那个不是同一个;需要合并回默认库时用 OPENCODE_DISABLE_CHANNEL_DB 环境变量。
opencode export 默认不脱敏。 会踩是因为命令名让人以为导出的是”结构化摘要”,实际导出的是会话正文。代码里 --sanitize 是一个可选布尔开关,不加就不走脱敏路径;加上之后,文本、推理内容、工具输入输出、文件路径与内容、补丁、快照等等会被替换成 [redacted:...] 形式的占位。避法:只要导出物要发给别人(提 issue、贴群里、存共享盘),一律先加 --sanitize,然后自己再扫一遍。
别把 opencode db "<SQL>" 当成安全的只读窗口。 会踩是因为它看起来像个查询工具,实际上执行的是你给的原始 SQL,不带查询命令还会直接开一个交互式 sqlite 会话。避法:只用它做核对,写操作交给应用本身;真要试探性地翻数据,先用 OPENCODE_DB 指到一份拷贝上折腾。
同步不上时,先看序列号而不是看界面。 会踩是因为界面只显示连接状态,而真正的进度在序列表里。避法:查 event_sequence 表里目标会话对应的 seq 和 owner_id,跟另一端对一下——序列号追不上通常是重放被拒,owner_id 不是你这边则说明写权限还在别人手上。
别指望”两边都开着就会自动合并”。 会踩是因为习惯了带冲突合并的同步产品。避法:换机器时把会话归属显式抢过来(这正是 /sync/steal 端点做的事),并且认清抢过来之后另一端就不该再写了。
团队里想禁分享,写进项目配置而不是靠约定。 会踩是因为个人全局配置里关了、别人机器上没关,而 auto 模式会把每个新会话都自动公开。避法:按官方文档的做法把 share 设成 disabled 写进项目里的 opencode.json 并提交进 Git,让它对仓库里每个人生效。
读代码时先分清哪些是历史包袱。 会踩是因为 sync/README.md 描述的抽象已经不在原目录、storage/storage.ts 里的 JSON 层也不再是主线。避法:把 specs/storage/ 下那两份文档当作导航图先读一遍,它们逐组标注了迁移状态和必须保住的不变式,比一头扎进源码省事得多。
六、收尾
把 opencode 的存储侧压缩成三句话:本地是一个带 WAL 的 SQLite 库,位置由渠道和环境变量共同决定,旁边还留着一套按项目根提交哈希分目录的遗留 JSON;同步是一份按聚合分流、带序列号和写者归属的事件日志,通过四个 HTTP 端点加一条 SSE 长连接对齐;两层之间唯一的硬约束是同一时刻只有一个写者。
你要接着往下读的话,建议这个顺序:先 specs/storage/remove-opencode-db.md 拿到迁移全景和不变式清单,再 packages/core/src/database/database.ts 确认库在哪、开了什么,然后 packages/core/src/event/sql.ts 加 packages/core/src/event.ts 看事件怎么写怎么读,最后 packages/opencode/src/control-plane/workspace.ts 看客户端那条循环是怎么把它们串起来的。packages/opencode/src/sync/README.md 放在最后当设计注释读。
动手前的自检清单:数据目录整体在你的备份策略里吗;opencode db path 指向的库跟你以为的是同一个吗;share 的模式是项目级写死的还是靠个人自觉;上次发出去的导出文件加 --sanitize 了吗;以及那台”换过去继续聊”的机器,写权限到底在谁手上。这几条任何一条答不上来,先别急着接着往下折腾。
延伸阅读:Agent 权限设计的最小化原则 讲的是执行侧的边界怎么收,AI 工具引入的数据安全风险 则从团队角度给了一份更宽的检查面,跟本篇的存储视角正好互补。
本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 Agent 改坏了代码怎么退回去:opencode 的快照与回滚机制,和你自己 git commit 的边界 和 拆解终端编码 Agent opencode:事件清单如何撑起 TUI 与插件。