session-share 架构详解
解决什么问题:三个 agent,三套散落的会话
如果你同时用 Claude Code、Codex CLI、Pi 这三个 coding agent,大概率会遇到这种场景:
- 在 claude 里搞了半天某个 feature,session 结束;
- 第二天想接着干,但开了个 codex 窗口——claude 那个 session 的上下文 codex 看不到;
- 想复盘”昨天我在 pi 里改了哪些文件”,得手动翻
~/.pi/agent/sessions/里的 JSONL; - 多个 agent 改过同一个项目,但没有一个统一视图能告诉你”这个项目被谁动过、改了什么”。
session-share 就是冲着这个来的:它只读地扫描三个 agent 写在 ~/.claude/、~/.codex/、~/.pi/ 的 JSONL 会话文件,把它们归一化到一个 project-local 的存储(<project>/.session-share/),再用 CLI 和 MCP server 两种方式暴露出来。它不改造任何 agent,也不写入它们的目录。
一图看全
图里能直接读出来的几件事:
- 左边虚框 = 只读源。三个 agent 各自的 JSONL 路径不一样,sync 通过
cwd字段判断每条会话属于哪个项目。 - 右边虚框 =
session-share自身,中间一条sync → store → query的流水线,右边再分叉到CLI(给人用)和MCP Server(给 agent 在 handoff 时调用)。 Pi RPC是侧支:query → pi_rpc走虚线,因为它是 LLM brief 的可选子进程,失败时降级到结构化数据。~/.session-share/<slug>是审计入口——一个全局 symlink,指向每个项目的.session-share/。
三层管道,模块依赖无环
整张图的核心是一条单向管道,模块依赖严格无环:
cli ──┬──► sync ──► parsers ──► model │ │ ├──► query ──► store ──► model │ │ │ └──► pi_rpc └──► registry
mcp_server ──┬──► sync └──► query每一层的边界是硬的:
| 层 | 职责 | 关键类型 |
|---|---|---|
model/ | 纯 dataclass,无 IO,无外部依赖 | SessionHeader, Event, Metadata, TaskLine |
parsers/ | 唯一知道三个 agent JSONL schema 的地方 | ClaudeParser, CodexParser, PiParser |
store/ | 唯一碰 .session-share/ 磁盘格式的地方 | ProjectStore, SessionStore, IndexStore, MetadataStore, LinksStore |
sync/ | 发现 + 增量同步 + registry 清理 | sync_project(), discover_project_sessions(), register(), prune() |
query/ | 读侧 facade,CLI 和 MCP 共用 | list_sessions(), get_session(), search(), build_brief() |
pi_rpc/ | 无状态子进程驱动 pi --mode rpc --no-session | PiRpcClient, PiRpcResult |
cli / mcp_server | 薄壳,只 orchestrate sync + query | — |
cli 和 mcp_server 故意做得很薄——业务逻辑都在 sync 和 query 里。这意味着 CLI 加一个命令和 MCP 加一个 tool 的工作量基本一样,因为它们都只是在调同一组底层函数。
数据流:从散落的 JSONL 到归一化 Event
session-share sync <project> 一次同步做的事:
- 发现:对每个配置的
AgentSource,glob<root>/<pattern>,扫到所有 JSONL 文件; - 过滤:每个文件读前 50 行,取出
cwd字段(claude/pi 是cwd,codex 是payload.cwd),Path.resolve()后跟传入的 project path 比对——不匹配就跳过; - 增量:对匹配的文件算
sha256,跟已存档的rawDigest比;一样的就跳过,完全不重解析; - 归一化:让对应的 parser 把 JSONL 转成统一的
Event流; - 写盘:整文件覆盖
sessions/<sessionId>.jsonl(不可变,永远整文件覆盖),并 upsertindex.sqlite里的 sessions / session_tags / events FTS5 表; - 清理:扫一遍
~/.session-share/*,把指向已不存在项目的死 symlink 删掉。
容错策略:单个文件解析失败不致命,失败信息塞进 SyncResult.errors,继续处理别的文件。
项目存储:.session-share/ 里有什么
<project>/.session-share/├── sessions/<sessionId>.jsonl # 归一化会话,header 在第一行,后面每行一个 event├── index.sqlite # sessions + session_tags + events FTS5(纯加速层)├── metadata.json # 用户可编辑:每个 session 的 title/tags/notes/hidden/pinned├── links.json # 用户可编辑:跨 agent 的 task line 分组└── briefs/<sha256>.txt # 缓存的 LLM brief,内容寻址
~/.session-share/<slug> # symlink → <project>/.session-share/几个有意识的设计选择:
metadata.json和links.json故意是 JSON,不是 SQLite。它们是要被人手编辑、被 git 追踪的;sync 永远不动它们,所以你的 tag / 笔记 / link 不会被同步覆盖。index.sqlite是加速层,不是真相源。删了它再 sync 一次就重建。这意味着它永远不会”坏掉”——出问题就rm然后重 sync。sessions/<id>.jsonl不可变。sync 要么完全覆盖一个文件,要么完全跳过(基于 sha256),不存在”patch 一行”。briefs/内容寻址。cache key =sha256(json(sessions + rawDigests)),输入变了一点都会生成新的 key,所以 cache 永远不会”对旧输入给出新 brief”。
CLI 与 MCP:同一份 store,两种消费姿势
CLI 是 Click 的命令组,MCP server 是 FastMCP 的 stdio server。两者都是 sync / query 之上的薄壳,所以你用 CLI 还是 MCP 拿到的数据完全一致——没有第二份数据通路,也没有第二份缓存。
CLI 主要命令:
| 命令 | 作用 |
|---|---|
init <path> | 创建 .session-share/ 并注册全局 symlink |
sync <path> [--agent X] | 拉取匹配 cwd 的所有 session |
list [path] [--agent --since --tag --include-hidden] | 列出 session,pinned 优先 |
show <sessionId> | 完整 dump 一个 session |
search <query> | FTS5 全文检索 event 内容 |
brief [--scope recent|taskLine] [--raw] | 生成 handoff brief |
tag / note / hide / pin | 编辑 metadata |
link --name <title> <sid>... | 把多个 session 编成一条 task line |
audit | 列出所有注册过的项目 |
doctor <path> | 一致性检查(目前查 orphan metadata) |
MCP 暴露的六个 tool:
| Tool | 用途 |
|---|---|
init_project(project_path) | 幂等初始化,agent 在对话里就能 self-init |
sync_project(project_path, agent?) | 幂等同步,可指定单一 agent |
list_sessions(project_path, agent?, since?, tag?, include_hidden?) | 列出 session |
get_session(session_id, project_path) | 取单个 session 的 header + events + metadata |
search_sessions(project_path, query, limit?) | FTS5 搜索 |
get_handoff_brief(project_path, scope?, task_line_id?, limit?, llm_brief?) | 生成 handoff brief |
init_project 和 sync_project 是后加的两个幂等 MCP tool——它们让一个新进对话的 agent 不用 shell 就能完成 self-initialization。这是个关键缺口补丁:在那之前,agent 必须用 Bash 调 CLI 才能初始化项目,现在直接走 MCP。
brief 的五种 mode
get_handoff_brief / session-share brief 是 handoff 的核心。它有五种返回 mode,理解了它们就理解了整个 brief 的降级链:
mode | 触发条件 | 返回内容 |
|---|---|---|
empty | 项目里没有任何 session | 啥都没有 |
cached | 同样的输入集早先生成过 brief | 缓存的 text |
raw | 调用方传了 llm_brief=False | 结构化 data(sessions + rawDigests) |
llm | pi rpc 调用成功 | LLM 生成的 text |
fallback_raw | pi rpc 失败(超时 / 崩溃 / binary 缺失) | 结构化 data + fallback_reason |
cache 是内容寻址的,所以”用户说他刚刚又干了点活”的判断很自然——只要 sync 拉到了新文件或 rawDigest 变了,cache key 就变,直接落到 llm 或 fallback_raw 重新生成。
fallback_raw 不是错误,是正常路径之一。Pi 没装、pi 卡死、pi 输出超 10 MB 都会落到这里,agent 拿到结构化数据自己也能继续工作。
一个不显眼但关键的实现细节:_run_coroutine
brief 调 pi rpc 是异步的,但 build_brief 既要支持 CLI(同步上下文)又要支持 MCP server(已经在 event loop 里)。直接 asyncio.run() 在 MCP 那条路径上会爆 “event loop is already running”。
解法是一个叫 _run_coroutine 的小 helper:
- 调用方不在 event loop 里 →
asyncio.run(coro); - 调用方已经在 event loop 里(MCP server)→ 起一个 worker thread 跑
asyncio.run(coro),把结果 marshal 回来。
看起来像”过度工程”,但有一条回归测试 tests/test_query/test_brief.py::test_brief_llm_works_inside_running_event_loop 把这个约束钉死了——任何想”简化”它回 asyncio.run() 的 PR 都会让那条测试挂。这是用测试固化非显然约束的典型例子,值得在 PR description 里贴。
几条硬约束(违反就算 bug)
- Agent 源只读。永远不写
~/.{claude,codex,pi}/。tests/test_sync/test_importer.py在 sync 前后比对源文件 sha256 强制这一点。 - 项目身份 =
Path(cwd).resolve()。同路径 = 同项目;项目挪到别的路径 = 新项目(Phase 2 才支持手工合并)。 sessionId是 UUID,跨 agent 几乎不会碰撞,所以不需要 merge / conflict 逻辑。- 格式知识只能活在
parsers/和store/。其他模块不准直接读 JSONL 或 SQLite。这条规则让替换某个 agent 的 parser 不会牵动 query / cli / mcp。 metadata.json/links.json必须保持 JSON。手编、git-friendly、sync 不动它们——这条不动摇。
当前阶段
Phase 1 feature-complete:
- 23 个 SDD 任务全部完成(model / parsers / store / sync / query / cli / mcp);
- 安全与完整性加固(FTS5 异常输入兜底、source byte-hash 不变量测试);
- 两个幂等 MCP tool(
init_project/sync_project)让 agent self-initialize; - 配套的
session-share-recallskill,教 agent 什么时候该调哪个 MCP tool。
Phase 2 留了几个 extension point:watch mode(sync/discovery.py 已经暴露了 discover_project_sessions(),接 inotify 就行)、多路径项目身份、Web UI、SessionStart hook 注入。架构上故意留的口子,具体什么时候做看需求。
如果你在做一次好奇玩家的自我审查:session-share 不是 orchestration 层,不替任何 agent 决定下一步干啥;它是个审计 + handoff 数据层,把三个 agent 各自为政的会话文件缝成一份可查、可标、可交接的资料库。它解决的问题不大,但踩过的人才懂有多烦。