当开发团队同时使用 Claude Code 和 Codex 两个 AI 编程代理时,最头疼的问题通常不是单次问答质量,而是上下文不互通。Claude Code 在长会话拆解、跨文件重构和需求分析上表现稳定;Codex 在执行修改、调用 OpenAI 生态接口时更加直接。但两者安装在同一台机器上时,默认互不知道对方已经改过哪些文件、回答过哪些问题、留下过哪些决策。与其不断复制粘贴任务描述,不如在本地搭一座双向桥:通过一个共享目录、一组 JSON 消息和一个本地 HTTP 服务,让两个代理可以互相派发任务、回传结果、共享仓库变更记录。这篇文章围绕这个 local bridge 的最小实现展开,会先说明设计边界,再给出可运行的 Python 桥接脚本,最后覆盖安装和运行阶段常见的 codex cli binary not found、模型名不被识别、代理端点失败等高频问题。
1. 双向协作的核心难点:Claude Code 和 Codex 各自维护一套会话
1.1 两个代理的互补场景
在实际项目里,两个代理不一定是竞争关系,更多是分工关系。比如一个较大的后端重构任务,可以先让 Claude Code 分析现有接口、梳理依赖关系、给出分阶段改造方案;然后再让 Codex 去改代码、跑测试、处理编译错误。问题在于,Claude Code 和 Codex 各自的会话上下文是独立的。Claude Code 记得的方案,Codex 并不知道;Codex 改动的文件列表,Claude Code 也看不到。如果人工把一段描述复制过去,短任务还能接受,长任务很容易丢失关键信息,比如要绕过哪个模块、哪些文件可以改、哪些文件不能动、验收标准是什么。
这类需求催生了一个并不复杂但非常实用的工程组件:本地桥。它不改变两个 AI 代理本身的运行方式,也不要求它们共享同一个后台模型。桥只负责在两端之间传递结构化信息,让一方能向另一方派发任务、接收结果、查看执行状态。实现地点放在本机,不依赖云服务,因此离线可用,也便于审计。
1.2 本地桥接的定义与边界
本地桥接可以理解为一组轻量协议和工具。协议定义一个消息该包含哪些字段;工具提供 push、pull、update、list 等命令,让 Claude Code 和 Codex 都可以通过执行 shell 命令来读写消息。
这里要明确桥接的边界。桥不负责让两个代理直接看到对方的终端输出,也不负责替它们做语义理解。桥只解决三件事:
- 任务派发:Claude Code 创建一条 task_request,Codex 能看到。
- 结果回传:Codex 完成或失败后,创建一条 task_result,Claude Code 能看到。
- 状态同步:消息是否已经读取、任务是否完成、失败原因是什么。
边界清晰能避免把桥做成“又大又难维护的系统”。实际使用中,桥的角色更像一个邮局,而不是翻译器。两端仍然按照自己的方式理解任务,桥只保证信息不丢失、可追踪、可回放。
1.3 为什么优先选择本地文件加 HTTP 的轻量方案
设计桥接方案时,可以考虑数据库、消息队列、共享文件、HTTP 服务等形式。针对两个 CLI 代理协作的场景,本地文件加轻量 HTTP 是性价比最高的组合。
文件作为存储层有几个明显优势。消息是 JSON 文件,可以直接cat查看;消息修改支持 diff;任务状态可以纳入 git 审计;即使桥的 HTTP 服务没有启动,代理仍然可以通过 CLI 命令直接读写文件。HTTP 服务只作为可选的查看入口,不承担主要存储职责。这样即使 HTTP 进程崩溃,桥接数据也不会丢,只要.bridge/目录还在,就能恢复。
这里也要说明,如果团队使用人数较多,或需要一个常驻服务做任务路由、超时重试、权限控制,那么后续可以迁移到 SQLite 或真正的消息队列。文章先给出最小闭环,避免一开始就引入过重依赖。
2. 先摸清环境:CLI 安装、路径和可用扩展点
2.1 检查 Claude Code 与 Codex CLI 是否可用
在配置桥之前,先确认两个 CLI 在终端里能正常运行。输入以下命令:
claude --version codex --version如果命令行找不到claude或codex,说明 CLI 没有加入当前用户 PATH,或者安装目录不在预期位置。还可以用which查看实际可执行文件路径:
which claude which codex对桥接脚本来说,知道codex的完整路径很重要。因为桥接工具可能会在脚本内部调用另一个代理,如果只写codex,而运行环境 PATH 不完整,就会触发unable to locate the codex cli binary这类错误。这个问题在 VSCode 扩展、桌面应用等场景中尤其常见,因为图形界面进程的环境变量往往和终端不一致。
2.2 识别常见的 codex cli binary not found 问题
在一个已有的 Claude Code 会话里直接执行codex --version,如果报错内容类似:
unable to locate the codex cli binary. set codex cli path or ensure the executable is in PATH这说明当前进程没有找到 Codex CLI。可能原因有三类:
- PATH 不完整:Codex 安装在
~/.codex/bin或 npm 全局目录,但当前 PATH 没包含该目录。 - 环境变量丢失:桌面应用或插件启动时,没有继承终端里的 shell 配置。
- 安装不完整:Codex CLI 还没有安装成功,或安装后没有重新打开终端。
检查方式如下:
echo "$PATH" ls -l ~/.codex/bin/codex 2>/dev/null npm root -g 2>/dev/null如果确定 Codex 已安装但路径不在 PATH,可以临时导出:
export PATH="$HOME/.codex/bin:$PATH"如果使用本地桥脚本,建议在脚本里检测codex可执行文件,找不到时输出友好提示,而不是让上层代理误以为桥本身坏了。
2.3 Claude Code 与 Codex 的可扩展接口对比
要接入本地桥,不需要修改代理内部代码,只需要使用它们提供的调用外部命令能力。两者对比如下:
| 能力 | Claude Code | Codex CLI |
|---|---|---|
| 执行 shell 命令 | 支持,能直接运行 Bash 命令 | 支持,可通过命令行或脚本执行 |
| 项目级说明文件 | CLAUDE.md | AGENTS.md |
| 扩展指令 | Agent Skill / Hook / MCP | 自定义指令、配置文件、skills |
| 状态存储 | 本地会话 | 本地会话 |
| 适合桥接的方式 | 通过 Bash 调用 bridge.py | 通过 shell 调用 bridge.py |
基于这个对比,桥接方案可以只依赖“执行 shell 命令”这一能力。在 Claude Code 侧,把桥接命令写进项目说明或 Skill 中;在 Codex 侧,把桥接命令写进AGENTS.md中。两个代理都能在需要时调用同一套bridge.py,从而实现双向协作。
3. 设计桥接协议:先定义“消息”和“状态”两个实体
3.1 消息分类
桥接并不是简单的“一边发一句话,另一边回一句话”。为了让代理知道该怎么处理,需要给消息一个明确的 type。常见类型包括:
task_request:请求对方完成一个任务。task_result:任务执行完成或失败后的结果回传。query:询问对方状态、上下文、文件信息。notify:同步一次变更,不要求对方必须回复。
在实现中,task_result必须与某个task_request关联,这样发起方才知道结果对应哪个任务。关联字段使用task_id。一条task_request消息创建后,会有一个唯一 ID;task_result在创建时把这个 ID 带入task_id字段。
3.2 桥接目录结构与 JSON Schema
在本地方案中,所有消息都存放在项目根目录下的.bridge/messages/目录里。目录结构如下:
. ├── bridge.py ├── CLAUDE.md ├── AGENTS.md └── .bridge/ └── messages/ ├── 20250321102000-ab12cd.json ├── 20250321103000-ab34ef.json └── 20250321104000-ab56cd.json每条消息是一个 JSON 文件。核心字段如下:
| 字段 | 含义 | 示例 |
|---|---|---|
id | 消息唯一 ID | 20250321102000-ab12cd |
sender | 发送方,取值为claude、codex或user | claude |
recipient | 接收方,取值为claude、codex或all | codex |
type | 消息类型 | task_request |
title | 简短标题 | 重构 /users 接口 |
content | 正文内容 | 把列表接口改为分页... |
task_id | 关联的任务 ID,无关联可为空字符串 | 20250321102000-ab12cd |
status | 消息状态 | open |
created_at | 创建时间 | 2025-03-21T10:20:00+0800 |
updated_at | 最近更新时间 | 2025-03-21T10:30:00+0800 |
context | 可选的上下文信息,如目录、分支、改动文件 | {"cwd": "/workspace/project"} |
示例消息如下:
{ "id": "20250321102000-ab12cd", "sender": "claude", "recipient": "codex", "type": "task_request", "title": "重构 /users 接口", "content": "将现有列表接口改为分页,并补充单元测试。", "task_id": "20250321102000-ab12cd", "status": "open", "created_at": "2025-03-21T10:20:00+0800", "updated_at": "2025-03-21T10:20:00+0800", "context": { "cwd": "/workspace/project", "branch": "feature/user-pagination", "changed_files": ["server/routes/users.py"] } }status的生命周期建议保持简单:
open -> delivered -> done | failed | cancelled发起方创建消息后,状态是open。接收方执行 pull 后,状态变成delivered,表示已经被看到。接收方完成任务后,通过 push 发送task_result,同时把原消息状态更新为done或failed。
3.3 文件存储的读取策略和冲突处理
使用文件存储,最常见的问题是并发写。两个代理可能同时执行 push,或者一个 pull 一个 update 同时发生。为避免文件互相覆盖,可以采用“原子写”策略:先写一个临时文件,再通过os.replace或Path.replace替换目标文件。
读取时不要依赖文件系统的修改时间做排序,因为消息 ID 已经包含时间信息,所以直接按文件名排序即可。pull 之后把消息标记为delivered,但不删除文件。这样后续可以通过 list 命令查看完整历史,也能避免误删导致追踪困难。
4. 用标准库实现本地桥:一个 Python 脚本完成存储与交互
4.1 脚本入口与目录初始化
实现采用 Python 标准库,不依赖第三方包。脚本bridge.py放在项目根目录,运行时会在自身目录下创建.bridge/messages/。如果希望把桥的目录放在别处,可以修改BRIDGE_HOME常量的定义。
#!/usr/bin/env python3 """bridge.py - a local bridge for Claude Code and Codex.""" import argparse import json import time import uuid from pathlib import Path BRIDGE_HOME = Path(__file__).resolve().parent / ".bridge" MSG_DIR = BRIDGE_HOME / "messages" def now(): return time.strftime("%Y-%m-%dT%H:%M:%S%z") def ensure_dirs(): MSG_DIR.mkdir(parents=True, exist_ok=True) def save_message(msg): ensure_dirs() path = MSG_DIR / f"{msg['id']}.json" tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps(msg, ensure_ascii=False, indent=2), encoding="utf-8") tmp.replace(path) return path def load_messages(): ensure_dirs() result = [] for path in sorted(MSG_DIR.glob("*.json")): try: data = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError: continue result.append(data) return resultsave_message中先用.tmp后缀写临时文件,再用replace覆盖正式文件。这样即使写过程中进程被中断,旧文件也不会被破坏。load_messages按文件名排序,读取时不改变消息顺序。
4.2 CLI 子命令:push、pull、update、list
接下来实现四个子命令。push创建一条消息,pull读取并标记当前代理的未读消息,update更新状态,list输出消息概览。
def create_message(sender, recipient, msg_type, title, content, task_id="", context=None): if sender not in ("claude", "codex", "user"): raise ValueError(f"unknown sender: {sender}") if recipient not in ("claude", "codex", "all"): raise ValueError(f"unknown recipient: {recipient}") msg = { "id": time.strftime("%Y%m%d%H%M%S") + "-" + uuid.uuid4().hex[:6], "sender": sender, "recipient": recipient, "type": msg_type, "title": title, "content": content, "task_id": task_id, "status": "open", "created_at": now(), "updated_at": now(), } if context: msg["context"] = context save_message(msg) return msg def cmd_push(args): context = {"cwd": args.cwd} if args.cwd else None if args.files: context = context or {} context["changed_files"] = args.files.split(",") msg = create_message( args.sender, args.recipient, args.type, args.title, args.content, args.task_id, context, ) print(json.dumps(msg, ensure_ascii=False, indent=2)) def cmd_pull(args): pulled = [] for msg in load_messages(): if msg["recipient"] not in (args.agent, "all"): continue if msg["status"] != "open": continue msg["status"] = "delivered" msg["updated_at"] = now() save_message(msg) pulled.append(msg) if pulled: print(json.dumps(pulled, ensure_ascii=False, indent=2)) else: print("no open messages") return 1 return 0 def cmd_update(args): updated = False for msg in load_messages(): if args.message_id and msg["id"] != args.message_id: continue if args.task_id and msg["task_id"] != args.task_id: continue msg["status"] = args.status if args.note: msg["note"] = args.note msg["updated_at"] = now() save_message(msg) updated = True if not updated: print("message not found") return 1 print("updated") def cmd_list(args): for msg in load_messages(): line = f"{msg['id']} {msg['status']} {msg['sender']}->{msg['recipient']} [{msg['type']}] {msg['title']}" print(line)pull 命令是“读取并标记”的语义。它每次只处理open状态的消息,处理完以后状态变为delivered,不会重复拉取。这样即使代理在抓取到消息之后中途崩溃,任务仍然可以从delivered状态恢复。
4.3 可选的本地 HTTP 状态服务
CLI 命令已经足够实现双向协作。增加 HTTP 服务是为了方便人类开发者查看状态,或给其他工具提供只读接口。服务只监听127.0.0.1,不允许外部网络访问。
from http.server import BaseHTTPRequestHandler, HTTPServer class BridgeHandler(BaseHTTPRequestHandler): def _send_json(self, data, status=200): body = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): if self.path == "/status": messages = load_messages() summary = { "total": len(messages), "open": sum(1 for m in messages if m["status"] == "open"), "delivered": sum(1 for m in messages if m["status"] == "delivered"), } self._send_json({"bridge_home": str(BRIDGE_HOME), "summary": summary}) return if self.path.startswith("/messages"): query = self.path.split("?", maxsplit=1)[-1] agent = "all" if query.startswith("agent="): agent = query.split("=", maxsplit=1)[-1] messages = [ m for m in load_messages() if m["recipient"] in (agent, "all") ] self._send_json({"messages": messages}) return self.send_error(404, "Not Found") def cmd_server(args): httpd = HTTPServer(("127.0.0.1", args.port), BridgeHandler) print(f"bridge server on http://127.0.0.1:{args.port}") try: httpd.serve_forever() except KeyboardInterrupt: pass/status返回消息总数和未读状态数量,/messages?agent=codex返回某个接收方的全部消息。HTTP 服务不用于处理创建和删除消息,只做状态查看。原因很简单:创建消息需要校验发送方和接收方,CLI 已经承担了这部分逻辑,再重复实现会增加维护成本。
4.4 主函数与命令行参数
主函数将子命令和对应处理函数绑定。所有参数都通过命令行传入,方便 Claude Code 和 Codex 在执行 shell 命令时展开变量。
def main(): parser = argparse.ArgumentParser(description="Local bridge between Claude Code and Codex") sub = parser.add_subparsers(dest="command", required=True) push = sub.add_parser("push") push.add_argument("--sender", required=True)