1. 为什么需要跨 CLI 编程 Agent 的会话接力
1.1 一个真实到让人头疼的场景
我平时的工作流里,终端窗口基本是常驻的。左边一个跑 Claude Code 做代码审查和重构,右边一个开 Codex CLI 处理批量脚本生成,中间还夹着一个跑测试的 shell。问题来了:当我在 Claude Code 里聊了半小时,把项目背景、约束条件、命名规范、踩过的坑全都喂给了它,结果切到 Codex CLI 想让它接着干同一件事的时候,一切归零。我得重新把上下文再讲一遍,而且讲得还不一定比第一次全。
这不是个别现象。只要你同时用两个以上的 CLI 编程 Agent,就一定会遇到这个断层。每个 Agent 都有自己的会话状态,存在自己的目录里,格式互不兼容,谁也不认识谁。Claude Code 把会话存在~/.claude/projects/下面,Codex CLI 存在~/.codex/sessions/里,两边都是 JSONL 或者 JSON 结构,但字段名、消息格式、角色定义全都不一样。
所谓会话接力,就是让一个 Agent 的对话历史,能够被另一个 Agent 读取、理解并继续。听起来简单,做起来要解决三个层面的问题:会话文件在哪、格式怎么转、接上之后怎么保证不串味。
1.2 会话接力到底解决什么问题
先说清楚价值,不然没必要折腾。
第一,省掉重复交代上下文的成本。一个成熟项目的背景信息,认真讲一遍至少五到十分钟,涉及技术栈、目录结构、代码风格、禁用库、历史决策。这些信息在 Agent A 里已经存在了,接力之后 Agent B 直接继承,不用重讲。
第二,发挥不同 Agent 的差异化能力。Claude Code 在长上下文理解和复杂重构上更稳,Codex CLI 在生成独立函数、写测试、跑批处理上响应更快。理想状态是:用 Claude Code 做架构设计和方案评审,把结论接力给 Codex CLI 去落地实现。两边各干各擅长的事,中间靠会话接力打通。
第三,保留决策链路。项目做到一半换工具,最怕的是丢失"为什么这么设计"的记录。会话历史里藏着大量决策依据,接力过来等于把决策链路一起带过去了。
1.3 适合谁来参考这套方案
这套东西不是给纯新手准备的。你需要满足几个前提:本地已经装好了至少两个 CLI 编程 Agent 并且能正常跑起来;对终端操作、文件路径、JSON 格式不陌生;最好懂一点 Python 或者 Node,因为格式转换那部分要写脚本。
如果你只是偶尔用一个 Agent,那没必要折腾。但如果你像我一样,日常在多个 Agent 之间来回切,或者团队里不同人用不同工具需要交接,那这套方案能省下大量重复沟通的时间。
提示:会话接力涉及读取和改写 Agent 的本地会话文件,操作前务必备份原始目录。改坏了顶多是丢会话,但丢的是你花时间聊出来的上下文,心疼。
2. 核心思路与方案选型拆解
2.1 三种可行路线对比
实现跨 CLI 会话接力,我实际试过三条路线,各有取舍。
| 路线 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 文件直转 | 解析 A 的会话文件,转成 B 的格式写回去 | 无需额外进程,纯离线 | 格式耦合强,Agent 升级易失效 | 一次性接力、低频使用 |
| 中间格式 | 定义统一的中间 JSON,双向转换 | 解耦,扩展新 Agent 成本低 | 需要维护转换层 | 多 Agent 长期混用 |
| 上下文注入 | 把 A 的摘要作为首条消息喂给 B | 实现最简单,不碰文件 | 丢失细节,只有摘要 | 快速接力、只要结论 |
我最后选的是中间格式路线。原因很直接:文件直转在 Agent 版本升级后经常崩,字段一改脚本就废;上下文注入又太糙,摘要会丢掉关键的代码片段和约束细节。中间格式虽然多写一层,但一次投入长期受益,而且中间格式本身就是一份可读的会话存档,出问题好排查。
2.2 中间格式怎么设计
中间格式的核心是只保留语义,不保留平台特性。我定义的 schema 大概长这样:
{ "session_id": "relay-20240101-001", "source_agent": "claude-code", "created_at": "2024-01-01T10:00:00Z", "project_path": "/Users/me/project", "messages": [ { "role": "user", "content": "把 utils 里的日期处理统一成 dayjs", "timestamp": "2024-01-01T10:00:05Z" }, { "role": "assistant", "content": "好的,我先扫描 utils 目录...", "timestamp": "2024-01-01T10:00:08Z" } ], "metadata": { "model": "claude-sonnet", "total_turns": 12 } }关键设计决策有三个。role 只保留 user 和 assistant 两种,因为不同 Agent 对 system、tool、function 这些角色的定义差异太大,强行映射会出错,工具调用记录我选择在转换时丢弃,只保留对话主干。content 统一成纯文本,多模态内容(图片、附件)在接力场景下价值有限,直接跳过。metadata 只放非关键的辅助信息,接力时目标 Agent 用不上,但排查问题时有用。
2.3 为什么不做全量转换
有人会问,为什么不把工具调用、文件 diff、执行结果全都转过去?
我的经验是:转得越多,错得越多。Claude Code 的一次工具调用记录里包含工具名、参数、返回结果、耗时,Codex CLI 那边的工具调用结构完全不同,硬转过去要么报错,要么被目标 Agent 当成无效历史忽略。更麻烦的是,某些 Agent 看到历史里有工具调用记录,会尝试"续上"那个调用,结果执行了不该执行的命令。
所以我的原则是:只接力对话,不接力动作。目标 Agent 拿到的是"我们聊了什么、结论是什么",至于中间执行过哪些命令,让它自己重新判断。这样既安全,又避免了格式地狱。
2.4 会话定位:怎么找到要接力的那个会话
这是实操里第一个卡点。Claude Code 的会话文件按项目路径哈希分目录,文件名是 UUID,光看文件名根本不知道哪个是哪个。Codex CLI 类似,按日期分目录。
我的做法是写一个会话索引脚本,扫描会话目录,提取每个会话的首条用户消息和最后修改时间,生成一张清单:
python3 session_index.py --agent claude-code --project /Users/me/project输出大概是这样:
[2024-01-01 10:00] 3f2a... "把 utils 里的日期处理统一成 dayjs" [2024-01-01 09:30] 8b1c... "重构 auth 模块,拆出 token 校验" [2023-12-31 18:20] 5d9e... "写一个批量重命名脚本"有了这张清单,接力的时候直接按时间或者关键词选,不用去猜 UUID。这个索引脚本本身也是中间格式的副产品,扫描的时候顺手就把会话解析成中间格式了。
3. 核心细节解析与实操要点
3.1 Claude Code 会话文件结构解析
Claude Code 的会话存在~/.claude/projects/<项目路径哈希>/下面,每个会话一个.jsonl文件,每行一条消息。单行结构简化后是这样:
{ "type": "user", "message": { "role": "user", "content": [{"type": "text", "text": "实际内容"}] }, "timestamp": "2024-01-01T10:00:05Z", "uuid": "..." }几个坑点要注意。content 是数组不是字符串,里面可能有 text、tool_use、tool_result 多种类型,解析的时候要按 type 过滤,只取 text。type 字段和 message.role 可能不一致,有些系统消息 type 是 user 但 role 是别的,判断角色要以 message.role 为准。时间戳是 ISO 格式带时区,转换时统一成 UTC。
3.2 Codex CLI 会话文件结构解析
Codex CLI 的会话在~/.codex/sessions/下,按年/月/日分目录,文件名是rollout-<时间戳>-<uuid>.jsonl。单行结构:
{ "timestamp": "2024-01-01T10:00:05.000Z", "type": "message", "payload": { "role": "user", "content": [{"type": "input_text", "text": "实际内容"}] } }和 Claude Code 的差异很明显:外层字段名不同(type vs type,但取值语义不同),内容类型名不同(input_text vs text),嵌套层级不同(payload 包一层)。这些差异就是转换层要抹平的地方。
3.3 转换层的三个关键处理
角色归一化。两个 Agent 都有 user 和 assistant,但 Codex CLI 还有 developer、system 等角色。我的处理是:user 和 assistant 原样保留,其他角色统一映射成 user,并在内容前加标记[系统上下文],让目标 Agent 知道这不是用户直接说的。
内容提取。写一个递归函数,遍历 content 数组,把所有 text 类型的片段拼起来。遇到 tool_use 或 tool_result 直接跳过。这里有个细节:如果一条消息里全是工具调用没有文本,这条消息就丢弃,否则会产生空消息。
时间戳统一。全部转成 ISO 8601 UTC 格式,秒级精度就够。时间戳在接力时其实用不上,但保留着方便排序和排查。
3.4 写回目标 Agent 的注意事项
把中间格式转成目标 Agent 格式写回去,比读取更危险,因为写错了可能让目标 Agent 启动就崩。
第一,不要覆盖已有会话。永远新建一个会话文件,让目标 Agent 以"新会话"的方式加载。覆盖已有会话一旦出错,原会话就没了。
第二,会话 ID 要新生成。用目标 Agent 期望的 UUID 格式,别用源会话的 ID,否则可能冲突。
第三,首条消息加接力标记。我会在第一条 user 消息前插入一段说明:
[会话接力] 以下内容来自另一个编程助手的会话记录,请基于这些上下文继续工作。这样目标 Agent 知道自己在接手,不会对历史消息里的指令产生困惑。
注意:不同版本的 Agent 对会话文件的校验严格程度不同。有的版本会校验 uuid 格式、时间戳格式、字段完整性,写回前最好先用一个空会话文件对照字段结构。
4. 完整实操流程与关键环节实现
4.1 环境准备与目录确认
先确认两个 Agent 的会话目录位置。Claude Code 默认在~/.claude/projects/,Codex CLI 默认在~/.codex/sessions/。如果你改过配置,去配置文件里找。
ls -la ~/.claude/projects/ ls -la ~/.codex/sessions/确认能看到会话文件后,先做一次全量备份:
cp -r ~/.claude/projects ~/.claude/projects.bak cp -r ~/.codex/sessions ~/.codex/sessions.bak备份这一步别省。我踩过一次坑,转换脚本有个 bug 把源会话文件写坏了,幸好有备份。
4.2 会话索引脚本实现
这个脚本负责扫描会话目录,输出可读清单。核心逻辑是遍历目录、解析每个会话文件、提取首条用户消息。
import json import os from pathlib import Path from datetime import datetime def index_claude_sessions(project_hash_dir): sessions = [] for f in Path(project_hash_dir).glob("*.jsonl"): first_user_msg = None last_mtime = f.stat().st_mtime with open(f, "r", encoding="utf-8") as fh: for line in fh: try: obj = json.loads(line) except json.JSONDecodeError: continue msg = obj.get("message", {}) if msg.get("role") == "user": content = msg.get("content", []) for c in content: if c.get("type") == "text": first_user_msg = c["text"][:50] break if first_user_msg: break if first_user_msg: sessions.append({ "file": str(f), "preview": first_user_msg, "mtime": datetime.fromtimestamp(last_mtime).isoformat() }) return sorted(sessions, key=lambda x: x["mtime"], reverse=True)跑一下就能看到清单。这个脚本我建议存成session_index.py,后面转换脚本会复用里面的解析逻辑。
4.3 中间格式转换实现
读取源会话,转成中间格式。以 Claude Code 为例:
def claude_to_intermediate(session_file, project_path): messages = [] with open(session_file, "r", encoding="utf-8") as fh: for line in fh: try: obj = json.loads(line) except json.JSONDecodeError: continue msg = obj.get("message", {}) role = msg.get("role") if role not in ("user", "assistant"): continue texts = [] for c in msg.get("content", []): if c.get("type") == "text": texts.append(c["text"]) if not texts: continue messages.append({ "role": role, "content": "\n".join(texts), "timestamp": obj.get("timestamp", "") }) return { "session_id": f"relay-{datetime.now().strftime('%Y%m%d%H%M%S')}", "source_agent": "claude-code", "created_at": datetime.utcnow().isoformat() + "Z", "project_path": project_path, "messages": messages, "metadata": {"total_turns": len(messages)} }Codex CLI 的解析逻辑类似,只是字段路径不同,把msg.get("content")换成obj.get("payload", {}).get("content"),把text类型换成input_text。
4.4 写回目标 Agent 实现
把中间格式转成 Codex CLI 的格式写回:
import uuid def intermediate_to_codex(intermediate, output_dir): session_uuid = str(uuid.uuid4()) now = datetime.utcnow() date_dir = output_dir / now.strftime("%Y/%m/%d") date_dir.mkdir(parents=True, exist_ok=True) out_file = date_dir / f"rollout-{now.strftime('%Y%m%dT%H%M%S')}-{session_uuid}.jsonl" with open(out_file, "w", encoding="utf-8") as fh: # 首条接力标记 marker = { "timestamp": now.isoformat() + "Z", "type": "message", "payload": { "role": "user", "content": [{"type": "input_text", "text": "[会话接力] 以下内容来自另一个编程助手的会话记录,请基于这些上下文继续工作。"}] } } fh.write(json.dumps(marker, ensure_ascii=False) + "\n") for m in intermediate["messages"]: line = { "timestamp": m["timestamp"] or now.isoformat() + "Z", "type": "message", "payload": { "role": m["role"], "content": [{"type": "input_text", "text": m["content"]}] } } fh.write(json.dumps(line, ensure_ascii=False) + "\n") return str(out_file)写完启动 Codex CLI,用--resume或者对应的会话恢复参数加载这个新会话,就能看到历史对话了。
4.5 一次完整的接力演示
假设我在 Claude Code 里聊了一个重构任务,会话文件是~/.claude/projects/abc123/3f2a.jsonl,现在要接力给 Codex CLI。
第一步,索引找到会话:
python3 session_index.py --agent claude-code --project /Users/me/project第二步,转成中间格式:
python3 relay.py export --agent claude-code \ --session ~/.claude/projects/abc123/3f2a.jsonl \ --project /Users/me/project \ --out /tmp/relay.json第三步,写回 Codex CLI:
python3 relay.py import --agent codex \ --input /tmp/relay.json \ --out ~/.codex/sessions第四步,启动 Codex CLI 恢复新会话,验证历史是否完整。
整个过程不到一分钟,比重新讲一遍上下文快得多。
5. 常见问题与排查技巧实录
5.1 接力后目标 Agent 不认历史
最常见的现象是:会话文件写进去了,但启动 Agent 后它像没看见一样,还是从空白开始。
排查顺序是这样的。先确认文件路径对不对,不同版本 Agent 的会话目录可能变过,去配置文件里核对。再确认文件格式,拿一个 Agent 自己生成的正常会话文件,和你的输出文件逐字段对比,看有没有缺字段或者字段类型不对。最后看时间戳,有些 Agent 会按时间戳排序,如果时间戳格式不对或者顺序乱了,可能加载失败。
我遇到过一次是时间戳精度问题,Agent 期望毫秒级,我写的是秒级,结果加载时解析失败但没报错,静默跳过了。
5.2 中文内容乱码
写文件时一定要指定encoding="utf-8",读的时候也一样。Python 在部分系统上默认编码不是 UTF-8,不指定就会乱码。另外json.dumps要加ensure_ascii=False,否则中文会被转成\uXXXX转义,虽然不影响解析,但可读性差。
5.3 会话太长导致加载慢
如果源会话有几百轮对话,全量接力过去,目标 Agent 加载会变慢,而且可能超出上下文窗口。
我的处理是做截断:只保留最近 N 轮,或者按 token 数估算,超过阈值就从最早的消息开始丢。丢的时候注意保持 user/assistant 成对,别丢出个孤立的 assistant 消息。
def truncate_messages(messages, max_turns=50): if len(messages) <= max_turns: return messages return messages[-max_turns:]5.4 工具调用记录导致的异常
前面说过要丢弃工具调用,但有时候源会话里工具调用和文本混在一条消息里。我的处理是只提取 text 片段,整条消息里如果没有 text 就丢弃。这样虽然会丢一些上下文,但避免了目标 Agent 误执行历史命令。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 目标 Agent 不认历史 | 路径错、格式错、时间戳格式错 | 对照正常会话文件逐字段比对 |
| 中文乱码 | 编码未指定 UTF-8 | 读写都加 encoding="utf-8" |
| 加载慢或超窗口 | 会话过长 | 截断到最近 N 轮 |
| 启动报错 | 字段缺失或类型错 | 用空会话文件对照 schema |
| 历史串味 | 接力标记缺失 | 首条消息加接力说明 |
| 源文件被改坏 | 脚本写回源目录 | 永远新建文件,不覆盖 |
5.6 几个我踩过的坑
坑一:以为会话文件是纯 JSON。其实是 JSONL,每行一个独立 JSON,用json.load整体加载会报错,必须逐行json.loads。
坑二:忽略了项目路径哈希。Claude Code 按项目路径哈希分目录,同一个项目在不同机器上哈希可能不同,接力时如果跨机器,要确认目标机器上项目路径一致。
坑三:直接改了源会话文件。有次图省事直接在源文件上改,结果脚本中途出错,源会话损坏。从此坚持"只读源、只写新"。
坑四:没考虑 Agent 版本差异。升级 Agent 后会话格式变了,老脚本直接失效。我的应对是把格式解析逻辑做成可配置的,字段路径写在配置里,升级时改配置不改代码。
6. 进阶玩法与扩展方向
6.1 双向接力与循环接力
单向接力跑通后,可以做成双向的:Claude Code 聊完接力给 Codex CLI,Codex CLI 干完再接力回 Claude Code。这样两个 Agent 形成一个工作闭环,各自发挥所长。
循环接力要注意避免上下文膨胀。每接力一次就多一层历史,几轮下来会话会变得很长。我的做法是每次接力时做一次摘要压缩,把早期对话用一段总结代替,只保留最近几轮原文。
6.2 接入更多 Agent
中间格式的好处在这里体现。要接入第三个 Agent,只需要写一个xxx_to_intermediate和一个intermediate_to_xxx,不用动其他代码。我目前接了 Claude Code、Codex CLI,还在试一个本地的开源 Agent,接入成本大概半小时。
6.3 团队协作场景
如果团队里有人用 Claude Code 有人用 Codex CLI,可以把中间格式的会话文件当成交接文档。A 做完设计,导出中间格式,提交到仓库的.relay/目录,B 拉下来导入自己的 Agent,直接接着干。这比写交接文档靠谱,因为上下文是完整的、可执行的。
6.4 自动化触发
我现在把接力做成了半自动:在 Claude Code 里输入特定指令,触发一个 hook 脚本,自动导出中间格式并提示"已准备好接力到 Codex CLI"。省掉了手动跑命令的步骤。
这个 hook 的实现依赖 Agent 的扩展机制,不同 Agent 支持程度不同,Claude Code 支持得比较好,Codex CLI 目前还得手动跑。
6.5 会话存档与检索
中间格式的会话文件本身就是一份干净的存档。我把所有接力过的会话存在~/.relay/archive/下,按项目和时间组织。需要找"上次那个重构是怎么决策的",直接 grep 存档目录,比翻 Agent 自己的会话文件方便得多。
grep -r "日期处理" ~/.relay/archive/ --include="*.json"这个检索能力是意外收获,但用起来很顺手,现在已经成为我工作流的一部分了。
最后分享一个小心得:接力脚本写完先拿一个无关紧要的测试会话跑通,确认目标 Agent 能正常加载、历史完整、没有报错,再去接力真正重要的会话。我一开始图快直接拿生产会话试,结果格式没调对,白折腾了半小时。测试会话花五分钟造一个,能省下后面一堆麻烦。