Yoda
参考Agent 设计指南Session

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.sqlitethreads.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 定义了 CustomTitleMessagetype:'custom-title',用户改名)和 AiTitleMessagetype:'ai-title',AI 生成),另有 tag(logs.ts:100-104)、last-prompttask-summary 等姊妹元数据条目。分两型是刻意设计,源码注释(logs.ts:67-74)列了三条理由:

  1. 读取偏好上 customTitle 永远压过 aiTitle
  2. reAppendSessionMetadata 永不重追加 AI 标题(防止 resume 时陈旧 AI 标题冲掉用户改名);
  3. VS Code 的 onlyIfNoCustomTitle CAS 检查只匹配用户标题——AI 可以覆盖自己的旧 AI 标题,但碰不到用户标题。

写入路径(至少 5 条)[一手源码]

  • /rename 命令 → saveCustomTitlecommands/rename/rename.ts:57);
  • /resume picker 内对任意会话改名(components/LogSelector.tsx:747,带目标文件路径,可以改别的会话);
  • ExitPlanMode 自动用计划名命名(source 'auto'ExitPlanModePermissionRequest.tsx:104);
  • SDK renameSession 控制请求(外部进程直接 append 到 JSONL);
  • AI 标题 saveAiGeneratedTitlesessionStorage.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 时把元数据重新追加到 EOFsessionStorage.ts:721-840)。两个关键细节:

  • 外部写者吸收:重追加前先做一次同步 tail 读(readFileTailSyncsessionStorage.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.sqlitethreads.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_strsession_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_namethread_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 CodeCodex CLI
名字存哪transcript JSONL 内部条目(custom-title/ai-titlerollout 外部: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 条目,读取链层层 fallbacksession_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.jsonl

Harness 接入建议(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.watch transcript 文件、tail 解析 custom-title/ai-title 行且 customTitle 优先(claude-title-source.ts:19-23, 183-199,与 CC 自身 readLiteMetadata 偏好一致);Codex 侧用 better-sqlite3 直接读 state_5.sqlitethreads 表(codex-title-source.ts:95)。
  • 合并规则写死在 applyTitlesession-title-manager.ts:69-73):titleSourceuseryoda 时直接 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-server thread/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.jsonl legacy 路径;
  • CC 源码快照为 2026-03-31,落后线上约两个月——条目类型可能已有新增。

参考资料

  • CC 源码:types/logs.ts:61-104utils/sessionStorage.ts:721-840, 2617-2673, 4767-4775utils/sessionStoragePortable.ts:17, 209-282
  • Codex 源码:rollout/src/session_index.rsthread-store/src/local/update_thread_metadata.rs:497-521thread-store/src/local/read_thread.rs:274-306app-server/src/request_processors/thread_processor.rs:1415-1446, 3106-3121state/src/extract.rs:97-102
  • Yoda 实现:src/main/core/session-title/(manager + 双 source)、src/main/core/conversations/renameConversation.tscodex-session-id.ts
  • 前序报告:《手工川-session-state-sync-cc-vs-codex-2026-06-11-v0.1》(lite read / SQLite 索引全景)

On this page