# 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 ` - 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-*-.jsonl` - 向该 rollout 文件追加一组 Codex 用户消息记录:`response_item / message(role=user)` 和 `event_msg / user_message` - 事件内容使用 Boss 原始用户消息文本,而不是执行 prompt - 事件时间优先使用 Boss 消息的 `sentAt` - 事件写入成功后,再继续现有 `codex exec resume` ### 3. 任务负载补齐“Boss 原始消息”字段 现在任务里只有: - `requestMessageId` - `requestText` - `executionPrompt` 这还不够稳,因为后续去重和 Desktop 镜像需要区分: - 哪条 Boss 用户消息已经镜像过 - 这次镜像的真实显示文本是什么 - 这条消息的原始时间戳是什么 因此为 `MasterAgentTask` 增加: - `sourceMessageId?: string` - `sourceMessageBody?: string` - `sourceMessageSentAt?: string` - `mirrorBossUserMessageToCodexDesktop?: boolean` 对普通线程单聊: - `sourceMessageId = message.id` - `sourceMessageBody = message.body` - `sourceMessageSentAt = message.sentAt` - `mirrorBossUserMessageToCodexDesktop = true` 对主 Agent 直聊、`@主Agent`、托管中转等不应写进子线程 Desktop 历史的场景,不开启这个标记。 ### 4. 去重策略 同一条 Boss 消息可能因为: - 任务重试 - local-agent 重启 - claim / complete 重放 而被多次处理。为避免在 Desktop 线程里重复写入同一条用户消息,本次采用“rollout 末尾去重”: - 生成稳定镜像 key:`boss-user::` - 写入的 `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_at` - `updated_at_ms` - `has_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 行为