乐于分享
好东西不私藏

给 Codex.app 装个遥控器续集:让所有 Provider 共用一份历史

给 Codex.app 装个遥控器续集:让所有 Provider 共用一份历史

昨天我解决了“怎么一键切 Provider”。今天发现真正麻烦的不是切过去,而是切过去以后,上一段会话的记忆不见了。


昨天那篇文章里,我给 Codex.app 写了一个 cx 命令。

终端输入 cx,选一个 Provider,脚本自动改 ~/.codex/config.toml~/.codex/auth.json,再重启 Codex.app。原本要手动改文件、复制 Key、重启应用的事,变成了一个菜单。

我以为这就够了。

用了两天才发现:Provider 是切过去了,但 Agent 的记忆没跟过去。

为什么这个问题必须修

最典型的场景是这样的。

你在官方账号下面做一个开发任务。比如排查一个 Tauri 打包问题,前面已经让 Codex 看过 workflow、脚本、日志和失败原因。它知道现在卡在哪里,也知道上一轮分析留下了哪些线索。

做到一半,官方账号额度快用完了。

这时候你不想停。最自然的办法是切到第三方 Provider,比如 MiniMax、Fucheers、Kimi 或 DeepSeek,继续让 Agent 干活。

但如果切完以后历史不见了,问题就来了。

新的 Provider 进入会话时,像一个刚进项目的人。它不知道你前面查过什么,不知道哪个文件已经看过,不知道哪个方案刚被否掉,也不知道你现在真正要它接着做哪一步。

你只能重新解释一遍:

  • • 我们刚才在修什么
  • • 哪些路径已经排过了
  • • 现在失败点在哪里
  • • 哪些猜测已经证伪
  • • 下一步应该沿着哪个方向继续

这很烦。更糟的是,开发任务里的上下文通常不是一两句话,而是一整段已经被压缩过的工作记忆。

上面这张图里,Codex 已经把前面的排查过程压缩成上下文继续往下走。这个上下文如果断掉,新模型进来就会像没看过前情提要一样,接力失败。

所以今天继续优化 cx

目标很明确:切 Provider 的同时,保留上一段会话历史,让新的 Provider 能接着旧上下文继续干活。

一开始我以为只是列表没刷新

昨天的脚本只动两个文件:

~/.codex/config.toml
~/.codex/auth.json

这两个文件负责 Provider、模型和凭证。

但切到第三方 Provider 后,我打开 Codex.app,左侧历史列表空了。上午在官方账号里做的任务没有出现。

我一开始以为只是 UI 没刷新,或者历史被藏在另一个列表里。后来发现不是。

对话历史根本不在 config.tomlauth.json 里。

我顺着 ~/.codex/ 看了一圈,真正可疑的是这些文件:

       
                                           
文件/目录作用
sessions/2026/06/10/*.jsonl活跃会话原文
archived_sessions/rollout-*.jsonl归档会话
session_index.jsonl会话索引
state_5.sqliteCodex.app UI 状态
state_5.sqlite-wal / state_5.sqlite-shmSQLite 的 WAL 文件
history.jsonl顶层输入历史
       
     

其中最关键的是 state_5.sqlite

这不是配置文件,而是 Codex.app 的状态数据库。左侧历史列表,很大概率就是从这里读出来的。

打开 SQLite 后,问题变清楚了

我看了一下 threads 表结构:

CREATE TABLE threads (
    id TEXT PRIMARY KEY,
    rollout_path TEXT NOT NULL,
    ...
    model_provider TEXT NOT NULL,
    ...
    cli_version TEXT NOT NULL DEFAULT '',
    first_user_message TEXT NOT NULL DEFAULT '',
    ...
    preview TEXT NOT NULL DEFAULT ''
);

CREATE
 INDEX idx_threads_provider ON threads(model_provider);

这个 model_provider 字段一下就很可疑。

它还有一个专门的索引:

CREATE INDEX idx_threads_provider ON threads(model_provider);

这说明 Codex.app 很可能会按当前 Provider 过滤历史。

我查了一下分布:

SELECT model_provider, COUNT(*)
FROM
 threads
GROUP
 BY model_provider;

结果大概是这样:

model_provider   n
--------------  ---
Fucheers        74
openai          44

上午在官方账号里做的任务,model_provider = 'openai'

下午切到 Fucheers 后,当前 Provider 变成了 Fucheers。如果 UI 查询类似这样:

SELECT *
FROM
 threads
WHERE
 model_provider = ?
ORDER
 BY updated_at DESC;

openai 的历史自然就被过滤掉了。

所以问题不是历史丢了,而是 Codex.app 按 Provider 分桶显示历史

第一版修法:把所有历史都改成当前 Provider

最直接的想法是:既然 UI 按 model_provider 过滤,那我就把所有 thread 的 model_provider 改成当前 Provider。

比如切到 Fucheers 时:

UPDATE threads
SET
 model_provider = 'Fucheers'
WHERE
 model_provider != 'Fucheers';

这样左侧列表查询 Fucheers 时,所有历史都能出现。

但不能直接这么做。

原因很简单:如果把原始 Provider 覆盖掉,以后就不知道这条会话最开始到底是 openai 还是 Fucheers 创建的了。脚本异常退出,数据也会变得很难恢复。

所以我加了一个影子列:

ALTER TABLE threads ADD COLUMN original_provider TEXT;

UPDATE
 threads
SET
 original_provider = model_provider
WHERE
 original_provider IS NULL;

original_provider 保存真实来源,model_provider 用来配合 UI 显示。

这就像给每条会话贴了两张标签:

  • original_provider:它原来属于谁
  • model_provider:现在让 UI 把它显示在哪个 Provider 下

切 Provider 时,只改 model_provider。以后想恢复,再用 original_provider 改回去。

SQLite 不能随便改,要先让它停稳

这里还有一个坑:state_5.sqlite 是 SQLite,而且启用了 WAL。

WAL 可以简单理解成 SQLite 的“临时账本”。应用还在退出时,有些写入可能还在 state_5.sqlite-wal 里,没有完全合并回主库。

所以脚本不能刚发完退出命令就立刻改 DB。

我做了几层保险:

osascript -e 'tell application "Codex" to quit'

sqlite3 "$STATE_DB" "PRAGMA wal_checkpoint(TRUNCATE);"

prev=-1
for
 i in 1 2 3 4 5; do
  cur=$(stat -f%z "$STATE_DB-wal" 2>/dev/null || echo 0)
  [ "$cur" = "$prev" ] && break
  prev="$cur"
  sleep
 0.3
done

然后再备份三件套:

cp -p state_5.sqlite     state_5.sqlite.bak.$ts
cp
 -p state_5.sqlite-wal state_5.sqlite-wal.bak.$ts
cp
 -p state_5.sqlite-shm state_5.sqlite-shm.bak.$ts

这一步不酷,但重要。

改用户本地状态库,第一原则不是写得漂亮,而是坏了能救回来。

第一次跑:看起来成功,其实没成功

我把脚本改完,跑了一次:

cx

选择 Fucheers。

脚本显示:

DB: 已更新 44 条 thread 到 model_provider=Fucheers

再查数据库,所有历史确实都变成了 Fucheersoriginal_provider 也保留得好好的。

我以为成了。

打开 Codex.app,上午那条官方账号里的会话还是没出现。

这就很离谱。

我又查了一次数据库,发现刚刚改成 Fucheers 的几条会话,竟然又变回 openai 了。

不是脚本没改成功。是 Codex.app 启动后,把它们改回去了。

真正的事实之源不是 DB,而是 rollout JSONL

我顺着 threads.rollout_path 找到对应的会话文件。

比如:

head -1 ~/.codex/sessions/2026/06/10/rollout-2026-06-10T09-20-23-*.jsonl

第一行是 session_meta

{
  "timestamp"
: "2026-06-10T01:20:23.xxxZ",
  "type"
: "session_meta",
  "payload"
: {
    "id"
: "019eaf1d-...",
    "model_provider"
: "openai"
  }

}

这下真相出来了。

state_5.sqlite 不是最终事实之源。它更像一个索引。

真正记录这条会话属于哪个 Provider 的,是 rollout JSONL 第一行里的:

"model_provider": "openai"

Codex.app 启动时会做一件事:

  1. 1. 从 SQLite 读 thread
  2. 2. 根据 rollout_path 找到 JSONL
  3. 3. 读取第一行 session_meta
  4. 4. 如果 DB 和 JSONL 不一致,就以 JSONL 为准,把 DB 修正回去

所以我只改 DB 没用。

Codex.app 一启动,就会“自愈”,把我改过的 model_provider 改回 rollout 里的原值。

这也是这次排障最重要的发现:DB 管显示索引,JSONL 管会话事实。

终极修法:DB 和 rollout JSONL 一起改

最终方案变成了两步:

  1. 1. 改所有相关 rollout JSONL 第一行里的 model_provider
  2. 2. 再改 SQLite 里的 threads.model_provider

顺序不能反。

因为 rollout 改写有可能失败,比如磁盘满、权限不够、某个文件正在被占用。如果先改 DB,rollout 没改成功,Codex.app 启动后还是会把 DB 改回去。

先改 rollout,再改 DB。最坏情况是 rollout 没改成,DB 也不动,原状态还在。

改 JSONL 我没有用 sed -i

原因是这些文件可能很大,而且 JSONL 是一行一个事件。我要改的只有第一行 session_meta,不能全文件盲替换。

我用了 Python 流式改写:

import os
import
 re
import
 sys

path, new_provider = sys.argv[1], sys.argv[2]

with
 open(path, "r", encoding="utf-8") as f:
    first = f.readline()
    if
 '"type": "session_meta"' not in first:
        sys.exit(0)

    new_first = re.sub(
        r'("model_provider"\s*:\s*)"[^"]*"'
,
        rf'\1"{new_provider}"'
,
        first,
        count=1,
    )

    if
 new_first == first:
        sys.exit(0)

    tmp = path + f".tmp-{os.getpid()}"
    with
 open(tmp, "w", encoding="utf-8") as out:
        out.write(new_first)
        for
 line in f:
            out.write(line)

    os.replace(tmp, path)

这里有几个关键点:

  • • 只读第一行,因为只有第一行是 session_meta
  • count=1,只改第一个 model_provider
  • • 剩余内容流式复制,不把整个大文件塞进内存
  • • 先写临时文件,再 os.replace() 原子替换

这不是为了优雅,是为了别把几百 MB 的历史文件写坏。

第二次跑:终于成了

这次再跑:

cx

切到 Fucheers,脚本输出类似这样:

DB: 已更新 44 条 thread 到 model_provider=Fucheers
Rollout: 改写 38 个 JSONL
Launched Codex

打开 Codex.app,上午那条官方账号里的任务终于出现在左侧列表里。

点进去,继续发消息。

新消息走的是当前激活的 Fucheers,而不是原来的 openai。也就是说:

  • • 历史能看见
  • • 上下文能接上
  • • 新请求走当前 Provider

这才是我真正想要的效果。

回滚也要设计成一键

这种脚本只写“前进”是不够的。

因为它改的是本地历史状态。如果哪天我不想合并历史,或者 Codex.app 升级后行为变了,我必须能一键退回去。

所以我加了 --restore-providers

cx --restore-providers

它做的事是:

  1. 1. 读取 original_provider
  2. 2. 把 threads.model_provider 改回原值
  3. 3. 把 rollout JSONL 第一行里的 model_provider 也改回原值
  4. 4. 再重启 Codex.app

我还加了一个临时开关:

cx --no-unify

这次只切 Provider,不合并历史。

如果想长期关掉,也可以在脚本顶部设:

UNIFY_THREADS=0

dry-run 让状态可见

这类脚本最怕黑盒。

所以我加了一个 dry-run:

cx --dry-run

它不会改任何东西,只打印当前分布:

threads 表当前 model_provider 分布:
model_provider  n
--------------  --
Fucheers        119

original_provider 影子列分布:
original_provider   n
-----------------  --
Fucheers           74
openai             44

这两列放在一起很有用。

model_provider 告诉我 UI 现在会把历史显示在哪个 Provider 下。original_provider 告诉我这些历史最初来自哪里。

一个管“现在怎么看”,一个管“原来是谁”。

这次学到的东西

第一,GUI 应用和 CLI 的状态模型完全不同。

CLI 更像一次性命令,环境变量、参数、配置文件进来,结果出去。GUI 应用有长时间运行的状态,有 SQLite,有索引,有归档文件,还有启动时的自我修正。

所以调 GUI 问题,不能只盯配置文件。

第二,“看起来成功”和“真的成功”之间,隔着一个启动周期。

我第一次改完 DB,SQL 查出来没问题,以为成了。结果 Codex.app 一启动,又按 JSONL 把 DB 改回去了。

以后遇到类似问题,不能只看静态文件,要看应用重启后是否还保持这个状态。

第三,索引不是事实之源。

state_5.sqlite 很像会话索引。真正的会话源数据在 rollout JSONL 里。你改索引,应用会用源数据把它校正回来。

这件事其实挺合理。只是当我从外部写脚本去改它时,必须尊重这个隐式规则。

第四,回滚不是锦上添花,是工具的一部分。

original_provider、DB 备份、JSONL 原子替换、--restore-providers,这些都不是为了显得工程化,而是为了让我敢用这个脚本。

如果一个工具只在一切顺利时好用,那它还不够好。真正需要设计的是出错时怎么退。

结语

昨天的 cx 解决了“怎么切 Provider”。

今天的优化解决了“切完以后怎么不断片”。

这两个问题连在一起,才算真的把 Codex.app 的多 Provider 工作流跑通:

官方账号额度快用完
→ cx 切到第三方 Provider
→ 旧会话还在
→ 新 Provider 接着旧上下文继续干活

对开发任务来说,这比单纯切模型重要得多。

因为 Agent 真正值钱的地方,不只是它能回答,而是它能沿着前面的工作继续走。上下文一断,它就又变回一个刚进项目的人。

这次改完以后,我终于可以放心一点:额度不够了,就切;Provider 想换,就切;任务上下文还在,工作不用从头讲起。

代码现在 526 行。

听起来有点长,但换来的是一个很舒服的动作:

cx

选一下,继续干活。