Session 命名同步
CC 把名字当 transcript 内的 append-only 条目(tail 窗口保活 + 来源分型),Codex 把名字放在 rollout 之外(SQLite + session_index.jsonl 双写)——多写者一致性两家给出两套答案
Session 命名同步
结论
会话名是典型的多写者字段:CLI 自己写、AI 自动写、SDK/IDE 外部写、harness 还要再写。两个 runtime 的解法完全不同。Claude Code 把名字做成 transcript JSONL 内部的 append-only 条目,且按来源分成两种类型(custom-title 用户改名 / ai-title AI 生成),靠「字段名不同 + 读取偏好 + 退出时重新追加(re-append)」三件套保证用户改名永远赢、且名字始终留在 64KB tail 读取窗口内。Codex 则把名字放在 rollout 文件外部,双写到 SQLite state_5.sqlite 的 threads.title 和 append-only 的 session_index.jsonl(newest-wins),rollout 本身不含名字。对 harness 的含义:读 CC 的名字 = tail 扫 transcript;读 Codex 的名字 = 查 SQLite(fallback 扫 index);写回则 CC 可直接 append、Codex 必须走 thread/name/set API 或双写两个存储。
研究问题
- 名字持久化在哪里?有几条写入路径,各自的时序与冲突语义?
- resume / 压缩 / 外部 SDK 写入时,名字会不会被覆盖或丢失?
- harness 如何读取、监听、写回名字而不与 runtime 自身的写入互相覆盖?
各 Agent 设计与实现
Claude Code
存储模型:名字是 transcript 内的条目,按来源分两型。 [一手源码] types/logs.ts:61-79 定义了 CustomTitleMessage(type:'custom-title',用户改名)和 AiTitleMessage(type:'ai-title',AI 生成),另有 tag(logs.ts:100-104)、last-prompt、task-summary 等姊妹元数据条目。分两型是刻意设计,源码注释(logs.ts:67-74)列了三条理由:
- 读取偏好上
customTitle永远压过aiTitle; reAppendSessionMetadata永不重追加 AI 标题(防止 resume 时陈旧 AI 标题冲掉用户改名);- VS Code 的
onlyIfNoCustomTitleCAS 检查只匹配用户标题——AI 可以覆盖自己的旧 AI 标题,但碰不到用户标题。
写入路径(至少 5 条)[一手源码]:
/rename命令 →saveCustomTitle(commands/rename/rename.ts:57);/resumepicker 内对任意会话改名(components/LogSelector.tsx:747,带目标文件路径,可以改别的会话);- ExitPlanMode 自动用计划名命名(source
'auto',ExitPlanModePermissionRequest.tsx:104); - SDK
renameSession控制请求(外部进程直接 append 到 JSONL); - AI 标题
saveAiGeneratedTitle(sessionStorage.ts:2667-2673,见《Session 自动重命名》章)。
saveCustomTitle 本体(sessionStorage.ts:2617-2638)就是一次 appendEntryToFile + 当前会话内存缓存 + tengu_session_renamed 遥测——没有锁,没有读-改-写,纯 append,靠「最后一条 wins」收敛。
保活机制:reAppendSessionMetadata。 listing/resume 用 head+tail 各 64KB 的 lite read(sessionStoragePortable.ts:17 LITE_READ_BUF_SIZE = 65536),名字条目会随会话变长滚出 tail 窗口。CC 的解法是在会话退出和 compaction 时把元数据重新追加到 EOF(sessionStorage.ts:721-840)。两个关键细节:
- 外部写者吸收:重追加前先做一次同步 tail 读(
readFileTailSync,sessionStorage.ts:2592-2614),用l.startsWith('{"type":"custom-title"')过滤出顶层条目(避免误匹配嵌进 tool_use 输入里的同名 JSON),若 SDK 进程在 CLI 持有会话期间写了更新的标题,CLI 缓存被刷新、重追加的是 SDK 的值而非陈旧值(sessionStorage.ts:740-755)。customTitle:""被视为「清除」。 - 重追加是无条件的:哪怕值还在 tail 窗口里也再写一条——compaction 后会话继续增长会把旧条目挤出窗口(
sessionStorage.ts:715-720注释)。
读取偏好链。 [一手源码] sessionStorage.ts:4767-4775:
const customTitle =
extractLastJsonStringField(tail, 'customTitle') ??
extractLastJsonStringField(head, 'customTitle') ??
extractLastJsonStringField(tail, 'aiTitle') ??
extractLastJsonStringField(head, 'aiTitle')不同来源用不同字段名,使「最后一次出现 wins」的字符串扫描天然完成来源优先级判定。
Codex CLI
存储模型:名字在 rollout 之外,双写两个存储。 rollout JSONL 的 SessionMeta 不含名字;改名走 apply_thread_name,[一手源码] thread-store/src/local/update_thread_metadata.rs:497-521:
async fn apply_thread_name(store: &LocalThreadStore, thread_id: ThreadId, name: String) -> ... {
if let Some(state_db) = store.state_db().await {
let updated = state_db.update_thread_title(thread_id, &name).await?...;
}
append_thread_name(store.config.codex_home.as_path(), thread_id, &name).await?...
}即先更新 SQLite state_5.sqlite 的 threads.title,再 append 一条到 ~/.codex/session_index.jsonl。后者结构 [一手源码] rollout/src/session_index.rs:20-25:{id, thread_name, updated_at},append-only、newest-wins,读取时从文件尾按 8KB 块反向扫描(session_index.rs:186-236)。
读取链:SQLite 优先,index 兜底。 read_thread 优先取 SQLite 的 title,缺失时回退 find_thread_name_by_id 扫 session_index(thread-store/src/local/read_thread.rs:274-276, 304-306,测试名直白:read_thread_uses_legacy_thread_name_when_sqlite_title_is_missing)。对外展示时若名字与 preview(首条用户消息)相同则视为「无名」(app-server/src/request_processors/thread_processor.rs:3106-3121 attach_thread_name)——因为 ThreadMetadata.title 的默认值就是首条用户消息文本(state/src/extract.rs:97-102),「自动 title」和「显式命名」共享一个字段,靠与 preview 比对区分。
按名找会话有防陈旧逻辑。 find_thread_meta_by_name_str(session_index.rs:117-155)从尾部流式产出匹配 id,且每个 id 只认它最新的名字记录(session_index.rs:174-180,防止改名后的历史名被当作现行名),再逐个验证 rollout 可读——未落盘的改名不能遮蔽同名旧会话。
改名 API:thread/name/set。 [一手源码] app-server-protocol/src/protocol/common.rs:507;处理器 thread_set_name(thread_processor.rs:1415-1446)规范化名字 → update_thread_metadata(loaded 线程走 LiveThread 与 rollout 写入保序,cold 线程直写 store,core/src/thread_manager.rs:475-505)→ 广播 ThreadNameUpdatedNotification。fork 时源线程名字会复制给新线程(thread_processor.rs:3159-3162, 3255-3257)。
差异矩阵
| 维度 | Claude Code | Codex CLI |
|---|---|---|
| 名字存哪 | transcript JSONL 内部条目(custom-title/ai-title) | rollout 外部:SQLite threads.title + session_index.jsonl 双写 |
| 来源区分 | 条目类型 + 字段名分型(user vs AI),读取偏好硬编码 | 单一 title 字段,「自动值 = 首条用户消息」靠与 preview 比对识别 |
| 冲突收敛 | append-only + 最后条 wins + 退出时 re-append 吸收外部写 | SQLite 行级覆盖 + index newest-wins 反向扫描 |
| 防滚出读取窗口 | reAppendSessionMetadata 重追加至 EOF(64KB tail 窗口保活) | 不需要(SQLite 点查 / index 反向扫描无窗口问题) |
| 外部写回方式 | 直接 append JSONL 条目(SDK renameSession 即如此) | thread/name/set JSON-RPC;绕过 API 须自行双写两存储 |
| 改名通知 | 无主动通知(外部靠 watch 文件) | ThreadNameUpdatedNotification 推送给已连接客户端 |
| 历史包袱 | 旧会话无 last-prompt/ai-title 条目,读取链层层 fallback | session_index 是 SQLite 之前的旧机制,保留作 legacy fallback |
最小复现
# CC:看名字条目(注意可能出现多条,最后一条 wins)
tail -c 65536 ~/.claude/projects/<slug>/<session-id>.jsonl | grep -o '{"type":"custom-title".*' | tail -1
# Codex:SQLite 主路径
sqlite3 ~/.codex/state_5.sqlite "SELECT id, title, first_user_message FROM threads ORDER BY updated_at DESC LIMIT 5;"
# Codex:index 兜底路径(newest-wins,同 id 取最后一条)
tail -5 ~/.codex/session_index.jsonlHarness 接入建议(Yoda 实践)
Yoda 的方案是不写回 runtime,名字主权收归自己的 DB,runtime 侧的名字仅作单向输入源:
- 自有
conversations.title+titleSource ∈ {user, yoda, agent}三态(src/main/core/conversations/renameConversation.ts:19-22)。renameConversation只写 Yoda DB,不回写 CC transcript / Codex store。 SessionTitleManager按 runtime 注册 watcher(src/main/core/session-title/session-title-manager.ts:14-21):CC 侧fs.watchtranscript 文件、tail 解析custom-title/ai-title行且 customTitle 优先(claude-title-source.ts:19-23, 183-199,与 CC 自身readLiteMetadata偏好一致);Codex 侧用 better-sqlite3 直接读state_5.sqlite的threads表(codex-title-source.ts:95)。- 合并规则写死在 applyTitle(
session-title-manager.ts:69-73):titleSource为user或yoda时直接 return——runtime 的自动名只是 interim,永不覆盖用户/产品改名;这同时挡掉了 resume 时回放的陈旧 title 行。 - Codex 特有的对账问题:Yoda 的 conversationId ≠ Codex threadId,靠
codex-session-id.ts用 title 精确匹配 + createdAt 邻近(±2 分钟)在 SQLite 里反查 threadId(codex-session-id.ts:24-46)。教训:spawn Codex 时就应该捕获并持久化 threadId(如解析首条 rollout 或SessionConfigured事件),事后模糊匹配是补救不是设计。 - 若你的产品需要「改名后 CLI 内也可见」:CC 可直接 append
custom-title条目(注意 CLI 在持有会话时退出会做 tail 吸收,时序安全);Codex 必须走 app-serverthread/name/set,手写 SQLite 会和进程内缓存、index 脱节。
失效条件
- CC 改变
Entry类型集或LITE_READ_BUF_SIZE(64KB tail 窗口),或reAppendSessionMetadata的吸收逻辑变更——重验外部写者安全性; - Codex
state_5.sqlite升级到state_6+ 或threads表结构变化(Yoda 的 SQL 直读会断); - Codex 把名字写进 rollout
SessionMeta(当前明确不写),或废弃session_index.jsonllegacy 路径; - CC 源码快照为 2026-03-31,落后线上约两个月——条目类型可能已有新增。
参考资料
- CC 源码:
types/logs.ts:61-104、utils/sessionStorage.ts:721-840, 2617-2673, 4767-4775、utils/sessionStoragePortable.ts:17, 209-282 - Codex 源码:
rollout/src/session_index.rs、thread-store/src/local/update_thread_metadata.rs:497-521、thread-store/src/local/read_thread.rs:274-306、app-server/src/request_processors/thread_processor.rs:1415-1446, 3106-3121、state/src/extract.rs:97-102 - Yoda 实现:
src/main/core/session-title/(manager + 双 source)、src/main/core/conversations/renameConversation.ts、codex-session-id.ts - 前序报告:《手工川-session-state-sync-cc-vs-codex-2026-06-11-v0.1》(lite read / SQLite 索引全景)