handoff
handoff 让你在一个 agent 里正进行的对话,实时出现在另一个 agent 的历史里。在 Claude Code 里干活,关掉,打开 Codex —— 对话就在那儿。反过来也一样。
都是可选的。
- [install|status|uninstall]
这些是 CI 用来评测这个技能的 prompt,每次发布都会跑。
会触发
- I want my Codex conversations to show up in Claude Code's sidebar, and the other way round.
- 让 Codex 能看到 Claude 里的会话,反过来也要
- Can I pick this conversation up in Codex without re-explaining everything?
不触发
- My other Claude account's conversations are missing from the sidebar.
- Put these conversation titles onto the dated scheme.
为什么这件事其实很小
两边收敛到了同一套东西,大概是互相借鉴的结果。
它们在相同的事件上触发 hook,配置格式也一样 —— Codex 的 hooks.json 用的就是 Claude Code 的 schema,连环境变量名都一样。而且两边都把历史写成「一行一个 JSON 对象」,边聊边追加,而不是结束时才存。所以任意一边的 hook 都能读到自己上次看过之后新增的部分,翻译成对面的格式写过去。
剩下的只是字段名对照:这边的 type:assistant 是那边的 response_item/message,content[tool_use] 是 function_call,以此类推。整个转换就是这张表。
它具体做什么
每次工具调用、每轮结束,hook 读取当前对话新增的行,翻译后追加到对面的镜像里。镜像是一条独立的对话,有自己的 id:往对面正开着的历史里追加,会跟那个工具自己的写入抢同一个文件,而一份写坏的记录比一份重复的记录代价大得多。
python3 scripts/handoff.py install # 两边都注册 hook
python3 scripts/handoff.py status # 配了哪些、各自读到哪儿
python3 scripts/handoff.py uninstall # 摘掉 hook,镜像保留install 会改两个你可能已经放了别的 hook 的文件 —— ~/.claude/settings.json 和 ~/.codex/hooks.json。它只碰命令里点名了这个脚本的条目,装第二次是替换而不是叠加。Codex 按哈希信任 hook,所以下一次 Codex 会话会让你确认一次。
它不是什么
它是一份可读的记录,不是可续跑的回放。 Codex 用自己的密钥加密推理内容,Claude 给 thinking 块签名,两边的签名都没法从对方的文本重建。所以推理内容能保下来的部分是纯文本摘要。你可以在对面读完整段对话、接着往下做;但不能像它本来就跑在那边一样直接 resume。
交接之后两份就各自长了。 在一边继续,不会回流到另一边。
它不会拖慢任何东西。 hook 不往 stdout 写任何内容,永远以 0 退出 —— hook 的输出会被跑它的 agent 当成反馈读进去,而一个失败的 hook 能中断一轮对话。做镜像属于记账,所以它的失败记在状态文件里、通过 status 浮出来,而不是打断你手头的活。
有一处是猜的
Codex 的 hook 载荷不会说它正在写哪个文件,所以 Codex 这一侧退回到「该目录下最近修改的那个 rollout」。同一个目录同时开两个 Codex 会话,有可能配错。配对之后会锁定它实际读过的那个文件,所以猜错的结果是多出一份错的镜像,而不是把对的那份写坏。
交给 agent 执行的完整指令
完整的 SKILL.md——这个技能触发时 Claude Code 读到的内容。上面是解释,这里是原件。
# Handoff
Mirror a live conversation into the other agent's history, so closing one tool and opening the other finds the work already there.
## Why this is possible at all
Claude Code and Codex converged on the same two mechanisms, which is the whole reason a bridge is cheap:
- **The same hook schema.** `~/.codex/hooks.json` uses Claude Code's shape — `PostToolUse`, `Stop`, `matcher`, `hooks[].command`, even `$CLAUDE_TOOL_ARG_*`. One script registers on both sides.
- **The same storage idea.** Both append one JSON object per line as the conversation runs. Neither writes a conversation only at the end, so a hook can read what has accumulated and translate just that.
They disagree on every field name, and that is all `scripts/formats.py` does:
| Claude Code | Codex |
|---|---|
| no header; `cwd` on every line | `session_meta`, first line only |
| `type:user`, `message.content` | `response_item/message` role=user |
| `type:assistant`, `content[text]` | `response_item/message` role=assistant |
| `content[thinking]` | `response_item/reasoning` |
| `content[tool_use]` | `response_item/function_call` |
| `type:user`, `content[tool_result]` | `response_item/function_call_output` |
| `parentUuid` linked list | file order, `turn_id` per item |
## Two things the existing converters get wrong
Worth knowing before reading their source, because both mistakes look like success.
**A mirror must be its own conversation.** Appending into a history the other tool has open races that tool's own writer. The mirror keeps its own id and its own file; the original is never touched.
**Writing the transcript is not enough on the Claude side.** The desktop app lists conversations from its own index under `~/Library/Application Support/Claude/claude-code-sessions/<account>/<org>/local_*.json`, not from `~/.claude/projects/`. A converter that writes only the transcript produces a file nothing displays. `write_index_entry` writes both, into the account the app is actually signed in as — which is `lastKnownAccountUuid` in the app's `config.json`, not the account `~/.claude.json` names.
Writing into Codex needs no equivalent: its catalogue is a projection a scanner derives from the rollout files, so the rollout is the whole job. Registering in its SQLite by hand, as `cc2cx` does, writes a row the scanner then regenerates.
## Run it
```bash
python3 scripts/handoff.py install # register the hooks on both sides
python3 scripts/handoff.py status # what is paired, how far each has read
python3 scripts/handoff.py uninstall # drop this skill's entries, keep the mirrors
```
`install` copies both modules to `~/.claude/handoff/` and points the hooks there. A plugin lives in a version-pinned cache directory, so a hook pointing straight at it stops resolving the moment the plugin updates — and the hook ends in `|| true`, which is what keeps a mirror from failing a turn and also what hides that breakage. **Re-run `install` after every plugin update**; that is what moves the runner to the new version.
It edits `~/.claude/settings.json` and `~/.codex/hooks.json`, touching only entries whose command is this skill's — matched on the command, not on a path, so uninstall also claims entries an older install left pointing into a plugin cache. Every other hook in those files is left as it was, and installing twice replaces rather than stacks. Codex trusts a hook by hash, so the first Codex session afterwards asks to approve it.
Say what `install` will edit before running it. These are the user's own hook configurations, and a hook appearing in them unannounced is indistinguishable from one they did not ask for.
## The hook contract
`mirror` writes nothing to stdout and always exits 0. A hook's stdout is read as feedback by the agent that ran it, and a non-zero exit can stop a turn. Mirroring is bookkeeping and has no business doing either, so every failure is swallowed and recorded in the state file instead — `status` is where a broken mirror surfaces.
Each side keeps a byte watermark, so a `PostToolUse` hook firing two hundred times translates each exchange once. `read_jsonl` stops at the last newline: the hook fires while the other tool is mid-write, and the final line is routinely half-written.
## What does not survive
Say this plainly when offering the skill, because "synced" implies more than this delivers.
A mirror is a **readable record of what happened, not a resumable replay.** Codex encrypts its reasoning under its own key and Claude signs its thinking blocks; neither signature is reconstructible from the other side's text. So Claude's thinking arrives as a reasoning summary, and Codex reasoning that carries only `encrypted_content` is dropped rather than forged into a block Claude cannot verify.
Once mirrored, the two conversations are separate. Continuing on one side does not reach back to the other.
## The one guess
Codex's hook payload does not name the rollout it is writing, so `mirror --from=codex` falls back to the most recently modified rollout declaring that working directory. Two Codex sessions in one directory can therefore pair to the wrong file. The pairing is keyed by the file actually read rather than by the directory, so a wrong guess produces one wrong mirror instead of corrupting a right one — but say it is a guess when reporting.
## Reporting
```
Handoff state <path>
<N> conversation(s) mirrored
Claude → Codex <started> <project>
read <N> bytes of source
last run <time>: <what it did, or the error it swallowed>
hooks in settings.json: installed (claude)
hooks in hooks.json: installed, but pointing at a plugin cache (codex)
```
Both hook lines matter. Installed on one side only is a one-way mirror, which looks like a working sync until the day it is needed in the other direction. And `pointing at a plugin cache` means an install predating this runner, or one that has not been re-run since a plugin update: that hook resolves until the cache directory goes, then silently stops.开发 里的其它技能
- sync/dev:syncsync 只做一件事:把本地和远端的差异摊开给你看,并且只执行其中绝对安全的那部分。
- ship/dev:shipship 把一次改动从工作区送到一个已合并的 PR:开分支、跑项目自己的测试、提交、开 PR、等 CI、合并,最后清理 worktree。
- cleanup/dev:cleanup一次改动发布之后,残留会留下来:本地分支、它在远端的副本、当时用的 worktree,有时还有一个只装着被忽略文件的目录。
- retitle/dev:retitle对话列表乱,不是因为名字不好听,是因为名字里没有可排序的信息。
- steward/dev:stewardsync、ship、cleanup 都只管你当下所在的那一个仓库。
- reunite/dev:reunitesessions 解决的是这一幕:你用另一个账号登录,侧边栏突然只剩下寥寥几条。