7.3 KiB
Codex Desktop 同线程消息镜像设计
目标:当用户在 Boss App 里对一个已绑定 codexThreadRef 的单线程会话发消息时,这条用户消息不仅进入 Boss 自己的项目账本和 conversation_reply 执行队列,也要被镜像进本机 Codex Desktop 的同一个线程历史里。这样用户稍后回到 Codex Desktop,看见的是同一个线程下连续的聊天记录,而不是 Boss 与 Desktop 两套割裂历史。
背景与现状
当前 Boss 的普通线程单聊主链是:
- Web / Android 调
POST /api/v1/projects/[projectId]/messages - 服务端写入 Boss 项目消息账本
- 服务端排一个
conversation_reply任务 - 本机
local-agent认领任务后调用codex exec resume <targetCodexThreadRef> - Codex 线程完成后,再把线程回复回写到 Boss 项目账本
这条链现在已经能做到“Desktop 回复被 Boss 看见”,因为 heartbeat 扫描 ~/.codex/sessions/.../rollout-*.jsonl 时,会把最近桌面 assistant 回复镜像回 Boss。缺口在反方向:
- Boss App 发起的用户消息只存在于 Boss 项目账本
codex exec resume虽然会把 prompt 交给目标线程继续执行,但 Boss 发起的这条消息并不会先出现在 Desktop 线程历史- 结果就是用户在 APP 和 Desktop 里看到的“同一个线程”并不是同一份完整聊天记录
方案对比
方案 1:直接操控 Codex Desktop GUI 输入并发送
优点:
- 理论上最贴近“像用户在桌面端亲自发了一条消息”
缺点:
- 依赖窗口前台、焦点、输入法、系统权限
- 极易被 Codex Desktop UI 更新打断
- 无法稳定支持后台运行和多线程并发
不推荐作为主方案。
方案 2:直接把 Boss 用户消息写入对应 Codex rollout JSONL,再继续现有 codex exec resume
优点:
- 与当前 Desktop/CLI 共用的真实线程存储一致
- 不需要操控 GUI
- 可以保持现有
local-agent -> codex exec resume主链不变 - 能与现有 heartbeat 读取 rollout 的能力形成闭环
缺点:
- 需要谨慎贴合 Codex rollout 事件格式
- 需要处理重复写入和 Desktop 刷新感知
这是本次推荐方案。
方案 3:单独给 Desktop 再建一条镜像线程
优点:
- 对现有线程文件侵入最小
缺点:
- 用户要的是“同一个线程”,不是“另一个镜像线程”
- 历史会继续分叉
不满足目标。
本次设计
1. 保持 Boss 账本为移动端/UI 主真相
Boss 的项目消息账本、会话排序、未读数、主 Agent 协同逻辑继续基于现有 boss-state.json。这次不把 Boss UI 改成直接读取 ~/.codex。
2. 对单线程 conversation_reply 任务增加“写入 Desktop 线程历史”的镜像步骤
当任务满足以下条件时,在 local-agent 侧做一次 rollout 镜像:
task.taskType === "conversation_reply"- 存在
targetCodexThreadRef - 存在用户原始消息文本
- 不属于
relayViaMasterAgent === true的接管中转任务
镜像行为:
- 优先通过
state_5.sqlite的threads.rollout_path定位目标 rollout 文件 - 如果本机 Codex 因版本/迁移差异无法稳定解析
state_5.sqlite,则回退扫描~/.codex/sessions/**/rollout-*-<threadId>.jsonl - 向该 rollout 文件追加一组 Codex 用户消息记录:
response_item / message(role=user)和event_msg / user_message - 事件内容使用 Boss 原始用户消息文本,而不是执行 prompt
- 事件时间优先使用 Boss 消息的
sentAt - 事件写入成功后,再继续现有
codex exec resume
3. 任务负载补齐“Boss 原始消息”字段
现在任务里只有:
requestMessageIdrequestTextexecutionPrompt
这还不够稳,因为后续去重和 Desktop 镜像需要区分:
- 哪条 Boss 用户消息已经镜像过
- 这次镜像的真实显示文本是什么
- 这条消息的原始时间戳是什么
因此为 MasterAgentTask 增加:
sourceMessageId?: stringsourceMessageBody?: stringsourceMessageSentAt?: stringmirrorBossUserMessageToCodexDesktop?: boolean
对普通线程单聊:
sourceMessageId = message.idsourceMessageBody = message.bodysourceMessageSentAt = message.sentAtmirrorBossUserMessageToCodexDesktop = true
对主 Agent 直聊、@主Agent、托管中转等不应写进子线程 Desktop 历史的场景,不开启这个标记。
4. 去重策略
同一条 Boss 消息可能因为:
- 任务重试
- local-agent 重启
- claim / complete 重放
而被多次处理。为避免在 Desktop 线程里重复写入同一条用户消息,本次采用“rollout 末尾去重”:
- 生成稳定镜像 key:
boss-user:<threadRef>:<sourceMessageId> - 写入的
event_msg中带上payload.metadata.bossSourceMessageId - 写入前读取 rollout 尾部固定窗口,检查最近是否已经存在同一
bossSourceMessageId - 若存在,则跳过写入,仅继续
codex exec resume
这样不需要引入新的状态库,也能与 Codex 原始线程文件保持局部自洽。
5. 刷新感知
第一版只写 rollout 还不够稳,因为 Desktop 的线程列表排序和“最近活跃”判断通常还依赖 threads.updated_at / updated_at_ms / has_user_event。因此本次实现改为:
- rollout append 成功后,若
state_5.sqlite可写且能命中该 thread,则同步刷新:updated_atupdated_at_mshas_user_event = 1
- 如果当前机器上的 Codex 状态库不可用、字段不兼容或压根没有这条 thread 记录,则只保留 rollout 写入,不把整条消息链路判成失败
这样做的取舍是:
- 先保证 Boss -> Codex Desktop 同线程历史不丢
- 再尽可能提升 Desktop 侧的列表刷新和最近活跃感知
- 不引入 GUI 自动化,不依赖桌面窗口前台
涉及文件
- 新增
local-agent/codex-thread-rollout-writer.mjs - 修改
local-agent/codex-task-runner.mjs - 修改
local-agent/server.mjs - 修改
src/lib/boss-data.ts - 修改
src/app/api/v1/projects/[projectId]/messages/route.ts - 修改
src/lib/boss-master-agent.ts(如果当前普通线程任务创建逻辑在这里有共用 helper,也一起补齐) - 新增测试
tests/local-agent-codex-rollout-writer.test.mjs - 修改测试
tests/local-agent-codex-task-runner.test.mjs - 修改测试
tests/single-thread-message-execution.test.ts
边界
- 本次只处理“Boss App -> 已绑定 Codex Desktop 同线程”的用户消息镜像
- 不处理群聊镜像到 Desktop
- 不处理主 Agent 自己的回复写入 Desktop 子线程
- 不做 Codex Desktop GUI 自动输入
- 不把 Boss 会话列表直接改成读取 Desktop 原始线程文件
验收标准
- 普通单线程会话发消息后,生成的
conversation_reply任务带有完整sourceMessage*字段 - local-agent 在执行
codex exec resume前,能把这条 Boss 用户消息写进目标 rollout - 同一
sourceMessageId重试时不会重复写入 rollout - 若状态库可用,镜像后会同步刷新 thread 的活跃时间和
has_user_event - 若状态库不可用或这台机器上的线程索引不完整,仍可通过
sessions回退找到 rollout 并完成消息镜像 - 现有普通线程回复链不回归,Boss 仍能收到 Codex 线程回复
- 若目标线程缺失、只读或 cwd 不合法,仍保持现有 fail-closed 行为