Skip to content

功能规格说明:会话管理 ​

规格文件:docs/specs/ui/session-management.md创建日期:2026-01-21

平台边界:前三个用户故事及会话存储相关功能需求为 CLI/SDK 宿主;会话列表聚合与恢复类用户故事(「跨目录会话聚合列表」「同仓库 worktree 会话聚合」「跨目录/worktree 恢复」「会话内热切换(/resume)」)为 CLI 宿主;「IDE 插件聊天头部」与「GUI 会话内热切换(/resume)」用户故事适用于 GUI 宿主(VSCE / JB / desktop);其中远程主机与分屏(pane)语义仅 desktop 适用。「会话自定义标题(重命名)」的存储与列表语义为 SDK 层(对所有读会话文件的宿主生效),改名入口三端都做:CLI 为 /rename 命令与选择器内就地改名,插件端(VSCE / JB)为头部标题就地编辑,桌面端为侧边栏会话行菜单(见 desktop-sessions.md)。

用户场景与测试 (必填) ​

用户故事:基于项目的组织(优先级:P1) ​

作为处理多个项目的开发者,我希望我的代理会话按项目的工作目录组织,以便我能轻松找到和管理与当前工作相关的会话。

优先级原因:面向开发者的代理的核心组织需求。

独立测试:在不同目录中创建会话并验证它们存储在 ~/.wave/projects 下的独立子目录中。

验收场景:

  1. 假设 有工作目录,当 创建会话时,则 它必须存储在以编码路径命名的子目录中。
  2. 假设 有较长或复杂的路径,当 编码时,则 它必须是文件系统安全的且限制为 200 个字符。

用户故事:高性能会话列表(优先级:P1) ​

作为拥有数百个历史会话的用户,我希望会话列表能快速加载,以便在启动或切换会话时不会体验到延迟。

优先级原因:随着会话数量增长,这对良好的用户体验至关重要。

独立测试:生成 100 个大会话文件并测量列出它们的时间;应该是近乎即时的,因为系统直接扫描会话目录、对每个文件只读取最后一条消息来提取时间戳与 token 数,不维护任何索引文件。

验收场景:

  1. 假设 有多个会话文件,当 列出会话时,则 系统直接扫描项目目录(~/.wave/projects/<编码路径>/),按文件名解析会话 ID(跳过 subagent- 前缀的子代理会话),对每个文件只读取最后一条消息以提取 lastActiveAt 与 token 数,按最后活跃时间倒序返回——不维护或读取任何 sessions-index.json 索引。
  2. 假设 会话文件损坏或为空,当 列出会话时,则 损坏文件跳过(解析失败视为不存在);空文件以文件修改时间作为 lastActiveAt 时间戳,不阻塞整体列表。

用户故事:会话延迟物化,元数据消息不落盘(优先级:P1) ​

作为 CLI 用户,我希望仅包含元数据消息(如 SessionStart hook 注入的 isMeta: true 上下文)的会话不创建 transcript 文件,以便 wave -r 会话列表不会出现「0 tokens / No content」的空壳会话(与 Claude Code 的 lazy materialization 行为一致)。

优先级原因:会话列表可见缺陷,且与 Claude Code 行为不一致;修复集中在 saveSession 单点,成本低。

独立测试:启动 Agent 后不发送任何消息直接 destroy(),验证 ~/.wave/projects/<编码路径>/ 下不产生该会话的 JSONL 文件;发送第一条真实消息后再次验证 meta 消息与真实消息一次性写入。

验收场景:

  1. 假设新会话仅包含 SessionStart hook 注入的 meta 消息(isMeta: true),当 saveSession(如 destroy 时)被调用时,则不创建会话文件、不写入任何内容,meta 消息仅保留在内存中
  2. 假设新会话先注入 meta 消息、随后用户发送第一条真实消息,当 saveSession 被调用时,则 meta 消息与真实消息一次性写入同一文件(meta 消息不丢失、顺序保持)
  3. 假设会话文件已物化(已有已保存消息)后运行时恢复会话并注入 meta 消息,当 saveSession 被调用时,则照常追加写入(与 Claude Code 一致:文件物化后所有条目都持久化)
  4. 假设 /clear 后新会话仅注入 meta 消息,当 saveSession 被调用时,则不创建新会话文件
  5. 假设从未落盘的 meta-only 会话,当运行 wave -r 时,则列表不显示该会话

用户故事:子代理会话分离(优先级:P2) ​

作为使用子代理的开发者,我希望子代理会话被清楚地标识并与主代理会话分离,以避免污染主会话历史。

优先级原因:在处理复杂的多代理工作流时提高清晰度。

独立测试:启动子代理并验证其会话文件以 subagent- 为前缀。

验收场景:

  1. 假设 子代理已创建,当 其会话被保存时,则 文件名必须以 subagent- 开头。

用户故事:IDE 插件聊天头部(优先级:P2) ​

作为 IDE 插件用户,我希望聊天面板顶部显示当前会话标题和常用操作(新建对话、历史对话、更多菜单),并能在输入框用 /resume 打开同一份会话列表,以便快速切换会话和管理当前会话。

为什么是这个优先级:会话切换与新建是高频入口,但用户仍可通过清空输入重新开始,属于体验增强。

独立测试:发送一条消息后观察标题栏显示会话标题;点击"新建对话"验证当前会话被清空;点击"历史对话"选择一个会话验证消息恢复。

验收场景:

  1. 假设会话中已有消息,当标题栏渲染时,则显示由首条消息截断得到的会话标题(最多 30 字符);无消息时显示"新对话"。
  2. 假设用户点击"新建对话"按钮,当当前不在响应流式输出中且当前会话无正在运行的后台任务时,则当前会话被清空,标题恢复为"新对话";流式输出期间或存在正在运行的后台任务期间按钮禁用。
  3. 假设用户点击"历史对话"按钮,当操作发生时,则弹出历史会话列表;选择其中一个会话后其消息被恢复到聊天面板(当前会话无正在运行的后台任务时;否则选择被忽略)。
  4. 假设用户点击"更多"按钮,当操作发生时,则弹出更多菜单(设置、企业控制台、登录/退出登录,见 sso-auth.md 的更多菜单条目)。
  5. 假设标题栏右侧显示上下文用量指示,当会话进行中时,则实时反映当前 token 使用情况(见 context-usage-indicator.md)。
  6. 假设当前会话存在正在运行的后台任务(后台终端工具/后台子代理/后台工作流,类型 shell/subagent/workflow 任一、状态 running),当用户点击"新建对话"或从"历史对话"列表中选择会话时,则两操作均被禁止——"新建对话"按钮与流式输出期间同样处于禁用态,历史会话选择被静默忽略(与流式输出期间的既有守卫行为一致),当前会话内容与后台任务保持原样、不被清空或中断;后台任务全部结束(或停止)后操作恢复可用。
  7. 假设与"新建对话"按钮同语义的 clearChat 其它触发路径(如手动输入 /clear),当当前会话存在正在运行的后台任务时,则同样被忽略,守卫与按钮一致(均为原地清空当前会话的操作;桌面端 /clear 不在本故事范围)。
  8. 假设用户在输入框输入 /resume(宿主本地命令,与其它本地命令一样出现在 / 弹窗中),当命令执行时,则打开与点击"历史对话"按钮完全相同的会话选择器——同一范围(当前工作区目录)、同一份磁盘会话来源(含 CLI 在该目录创建的会话)、同一搜索与键盘操作、同一条恢复路径,不离开面板、不清空当前对话。
  9. 假设用户通过 /resume 打开的列表选择会话,当当前会话正在流式输出或存在正在运行的后台任务时,则与点击"历史对话"列表选择的行为一致:选择被忽略(守卫同场景 6),当前会话与后台任务保持原样。
  10. 假设/resume 选择器已打开,当用户按 Esc 或点击选择器外部时,则关闭选择器,当前会话、消息与输入框草稿保持原样。(/resume 不因"统一为全部项目"而扩大范围——插件宿主无法切换工作区,跨项目恢复属于桌面端与 CLI 的能力,见「GUI 会话内热切换」故事。)

用户故事:跨目录会话聚合列表与移除 7 天窗口(优先级:P1) ​

作为在多个项目间切换的开发者,我希望 wave -r 选择器能列出所有项目的历史会话(Ctrl+A 切换,行尾显示项目路径),并且不再把超过 7 天未活跃的会话从列表中过滤掉,以便从任意目录找回并恢复任意项目的旧对话。

为什么是这个优先级:这是跨目录恢复的地基——没有全项目列表与完整历史,跨目录恢复无从选择;7 天窗口会隐藏用户以为还在的旧会话,与 Claude Code 行为不一致。

独立测试:在项目 A 与项目 B 分别创建会话;从项目 A 运行 wave -r,验证默认只见项目 A 的会话;按 Ctrl+A 后两个项目的会话都出现且项目 B 的行尾显示其路径;构造一个 8 天前活跃的会话文件,验证它仍出现在列表中(移除 7 天过滤)。

验收场景:

  1. 假设当前目录存在历史会话,当用户运行 wave -r(无参数)时,则默认列出当前目录的全部会话,按最后活跃时间倒序,不再按 7 天窗口过滤(超过 7 天未活跃的会话仍显示)。
  2. 假设存在多个项目目录(~/.wave/projects/ 下多个编码目录),当用户在 wave -r 选择器中按 Ctrl+A 时,则切换为全项目列表:遍历全部项目目录,行尾显示每个会话所属的项目路径(尽力解码回原始路径),按最后活跃时间倒序。
  3. 假设同一会话 ID 出现在多个项目目录(如多 worktree 分支),当聚合列表渲染时,则按 sessionId 去重,仅保留最后活跃时间最新的那条。
  4. 假设用户在 wave -r 选择器中再次按 Ctrl+A,当切换发生时,则列表回到"仅当前目录"范围,快捷键为双向切换。
  5. 假设项目目录名因超长路径被截断加哈希后缀、无法可靠解码回原始路径,当全项目模式展示项目路径时,则显示编码后的目录名作为兜底,不报错。

用户故事:同仓库 worktree 会话聚合(优先级:P1) ​

作为在 git 仓库多个 worktree 间工作的开发者,我希望 wave -r 选择器能发现同一仓库的其他 worktree 的会话(Ctrl+W 展开),以便在其中一个 worktree 里查看并恢复另一个 worktree 的对话。

为什么是这个优先级:同一仓库的多个 worktree 是高频并发工作形态,会话按 worktree 目录分散存储,跨 worktree 恢复是跨目录恢复中最常见的一类;与 Claude Code 的 worktree 感知行为对齐。

独立测试:在仓库的两个 worktree 中分别创建会话;在其中一个 worktree 里运行 wave -r,验证默认只显示当前 worktree 的会话;按 Ctrl+W 后两个 worktree 的会话都出现;单 worktree 或非 git 目录下 Ctrl+W 不生效。

验收场景:

  1. 假设当前目录位于 git 仓库内且该仓库有多个 worktree,当用户运行 wave -r 时,则选择器默认显示当前 worktree 的会话。
  2. 假设仓库有多个 worktree,当用户在 wave -r 选择器中按 Ctrl+W 时,则展开为同仓库全部 worktree 的会话(经 git worktree list 发现并匹配各 worktree 对应的会话目录),再次按 Ctrl+W 收回为仅当前 worktree。
  3. 假设仓库只有一个 worktree 或当前目录不在 git 仓库内,当用户按 Ctrl+W 时,则该快捷键不产生任何效果(底部提示不出现该快捷键)。
  4. 假设worktree 路径与普通项目路径可能前缀相同(如 repo 与 repo-foo),当worktree 目录匹配时,则按编码前缀长度优先匹配,避免短前缀误吞长路径的会话目录。
  5. 假设用户按 Ctrl+A 进入全项目模式后又按 Ctrl+W,当两个切换叠加时,则各自独立生效:全项目模式决定"哪些目录"、worktree 模式决定"是否限定同仓库 worktree",两者同时开启时列出全部项目(全项目模式优先,不再受 worktree 过滤约束)。

用户故事:跨目录/worktree 恢复(优先级:P1) ​

作为在任意目录使用 wave 的开发者,我希望从选择器选中会话后能直接恢复:当前目录会话原地恢复;同仓库其他 worktree 的会话自动切换到目标 worktree 后恢复;其他项目的会话得到可执行的 cd 命令,以便无需记忆会话 ID 就能继续任何历史对话。

为什么是这个优先级:恢复是列表的终点——能列出但恢复不了等于功能缺失;worktree 自动切换与跨项目 cd 命令分别覆盖"同仓库高频场景"与"跨项目低频场景",与 Claude Code 行为一致。

独立测试:在仓库 worktree B 创建会话后,在 worktree A 中经 wave -r + Ctrl+W 选中该会话,验证进程工作目录切换到 worktree B 且历史消息恢复;在另一个项目目录选中其会话,验证输出 cd <路径> && wave --restore <sessionId> 并复制到剪贴板。

验收场景:

  1. 假设用户选中当前目录的会话,当确认选择时,则直接恢复该会话(现有行为不变,消息历史载入、不改变工作目录)。
  2. 假设用户选中同仓库其他 worktree 的会话,当确认选择时,则直接恢复该会话,进程工作目录切换为该 worktree 路径,历史消息完整载入。
  3. 假设用户选中其他项目(非同仓库 worktree)的会话,当确认选择时,则不在当前进程恢复,而是显示 cd <项目路径> && wave --restore <sessionId> 命令并将该命令复制到剪贴板,随后退出选择器。
  4. 假设选中的 worktree 目录已被删除(git worktree remove 后),当用户确认选择时,则按"其他项目"处理:给出 cd 命令提示而不是报错崩溃。
  5. 假设用户直接运行 wave --restore <sessionId> 且该会话不在当前目录,当查找时,则扫描全部项目目录(含同仓库 worktree 目录),找到即恢复;全部找不到时给出明确错误(会话不存在),不静默地新建空会话。
  6. 假设用户运行 wave -c(--continue),当执行时,则行为保持不变:恢复当前目录最近一次会话,不跨目录。

用户故事:会话内热切换(/resume)(优先级:P1) ​

作为已经在一次对话中的开发者,我希望输入 /resume 就能列出历史会话并切换到其中一条,不必退出进程重新启动,以便在不丢失进程状态的前提下继续别处的对话。

为什么是这个优先级:换会话是会话功能里唯一仍需"退出重开"的高频动作。列表聚合、范围切换、归属判定与恢复语义都已为 wave -r 建好,会话内入口是这条链路的最后一段;缺失会让 CLI 与已支持进程内切换的 IDE 插件 / 桌面端体验断层。

独立测试:在目录 A 创建两条会话并在第一条里对话若干轮,在同一进程内输入 /resume 选中目录 A 的第二条会话,验证消息、用量与任务列表整体替换为目标会话且进程未退出;再切换到同仓库另一 worktree 的会话,验证工作目录随之切换、后续新消息追加到目标会话文件;按 Esc 取消,验证无任何副作用。

验收场景:

  1. 假设用户在一次对话中输入 /resume,当命令执行时,则在输入框上方打开会话选择器覆盖视图(不退出进程、不清空当前对话),支持 ↑↓ 选择、Enter 确认、Esc 取消,且范围切换(Ctrl+W 同仓库全部 worktree、Ctrl+A 全部项目)与快捷键提示同 wave -r 的选择器完全一致。
  2. 假设选择器已打开,当用户按 Esc 时,则关闭选择器,当前会话、消息与输入框内容保持原样。
  3. 假设用户选中当前目录的会话,当确认选择时,则在进程内切换:显示的消息流整体替换为目标会话的完整历史(含压缩前的显示历史),用量按目标会话重算,任务 / 待办列表替换为目标会话的任务,工作目录不变。
  4. 假设用户选中同仓库其他 worktree 的会话,当确认选择时,则在进程内切换并同时把工作目录切换到该会话所属目录;切换后涉及工作目录的派生内容(项目记忆、规则、目录相关的系统提示段)必须按新目录重新计算,不得沿用旧目录的缓存。
  5. 假设上一轮 /resume 已把会话切到 worktree B,当用户再次 /resume 并选中 worktree C(或切回原目录)的会话时,则切换同样成功——不允许出现"只能切换一次、之后再也切不动"的状态。
  6. 假设用户选中跨项目(非同仓库 worktree)的会话,当确认选择时,则不在当前进程恢复,而是在选择器内显示 cd <项目路径> && wave --restore <sessionId> 提示(可复制到剪贴板),进程不退出;关闭提示后原会话及其内容不受影响。(与"跨目录/worktree 恢复"故事场景 3 的唯一差别是入口在会话内,因此不退出进程。)
  7. 假设选中的 worktree 目录已被删除,当确认选择时,则按"跨项目"处理:给出 cd 提示而不是报错崩溃(与 wave -r 场景 4 一致)。
  8. 假设正在生成回复(加载中),当用户输入 /resume 时,则命令被忽略并提示,不中止当前回合、不切换会话(与 /clear 在运行中的既有口径一致)。
  9. 假设切换成功,当切换完成时,则会话 ID 与落盘项目目录同步变化:后续新消息必须追加写入目标会话原本所在的会话文件,不得在当前目录另建一份同名会话文件。
  10. 假设切换成功,当切换完成时,则输入框草稿被清空、加载态复位,并触发会话切换的生命周期钩子(上一会话的 SessionEnd、目标会话的 SessionStart,来源标记为"恢复")。
  11. 假设目标会话文件在磁盘上不存在或已损坏,当用户确认选择时,则切换不发生、给出失败提示,当前会话保持原状且可继续使用。

用户故事:GUI 会话内热切换(/resume)(优先级:P1) ​

作为在 IDE 插件或桌面端界面里的开发者,我希望在输入框里输入 /resume 就能打开会话选择器并切换到其中一条,以便不必离开当前界面、也不必重启应用,就能继续另一段对话;其中桌面端还应能列出命令行与其它客户端创建的会话(含远程主机上的),插件端则与当前宿主既有的历史会话入口保持同一范围。

为什么是这个优先级:GUI 里切换会话已有入口,但桌面端的会话树只收录本应用创建的会话——命令行那边开的对话完全不可见;插件端的历史弹窗已按工作区目录从磁盘读取,缺的只是输入框里的入口。/resume 是 GUI 与 CLI 行为对齐的最后一段,也是"跨客户端接着同一段对话"的唯一入口。

为什么插件端不扩到全部项目:插件宿主由绑定的工作区决定一切(会话落盘目录、工具作用域、打开的文件),列出其它项目的会话后既无法切换工作区、也无从恢复,只会与头部「历史对话」按钮的范围产生不一致。跨项目恢复是桌面端(可切换工作目录与主机的宿主)与 CLI(跨项目给 cd 提示)的能力。

独立测试:用 CLI 在某个项目目录创建一段会话(界面从未创建过它),在桌面端输入 /resume,验证列表中同时出现该项目与该会话(每行带项目路径),选中后当前分屏切换到该会话且消息历史与 CLI 中所见一致;在插件端(VSCE / JB)输入 /resume,验证打开的列表与头部「历史对话」按钮完全一致(当前工作区目录,含 CLI 在该目录创建的会话);再选中一条所属目录已被删除的会话,验证给出目录不存在的提示、且当前会话不受影响。

验收场景:

  1. 假设用户在 GUI(插件端或桌面端)输入 /resume,当命令执行时,则打开可搜索的会话选择器——插件端为面板头部下方的下拉弹窗(与"历史对话"按钮同一个),桌面端为居中模态对话框(渲染在应用根层,分屏状态下同样可用)——不离开当前界面、不清空当前对话,支持 ↑↓ 选择、Enter 确认、Esc 取消。
  2. 假设选择器打开,当列表加载时,则插件端(VSCE / JB)与该宿主头部「历史对话」按钮的范围完全一致(当前工作区目录的会话,按最后活跃时间倒序);桌面端列出全部项目(~/.wave/projects 下全部项目目录,跨目录去重后按最后活跃时间倒序),每行显示会话标题与该会话所属项目路径。
  3. 假设某条会话不是当前界面创建的(由 CLI、另一客户端、或同一台机器上另一个 wave 进程写入),当选择器列表加载时,则该会话在其所属目录范围内同样出现并可选——列表来自磁盘上的会话文件,而不是界面自己的会话索引(插件端即"CLI 在同一工作区目录开的会话",桌面端即"任意目录的会话")。
  4. 假设当前对话属于某台远程主机(桌面端),当选择器列表加载时,则列表为该主机磁盘上的会话(每行标注项目路径),不混入本地或其它主机的会话——范围随"当前对话所属主机"变化,切换焦点分屏后再次打开即换成该主机的会话;当前主机不可达时给出可见的失败提示(不得静默显示空列表),当前会话不受影响。
  5. 假设用户选中一条会话,当确认选择时,则在当前位置切换:消息流整体替换为该会话的完整历史,界面不重开、进程不退出;桌面端复用既有分屏切换路径(该会话已显示在其它分屏时聚焦那个分屏,而不是再开一份)。
  6. 假设选中的会话所属目录在对应主机上已不存在(插件端即工作区目录已被删除),当确认选择时,则不切换,给出「该会话的目录已不存在」提示;当前会话及其内容保持原状、可继续使用。
  7. 假设目标会话记录在磁盘上不存在或已损坏,当确认选择时,则切换不发生、给出失败提示,当前会话保持原状(桌面端按既有规则同时从会话索引移除该条目)。
  8. 假设当前会话正在流式输出、或存在正在运行的后台任务,当用户输入 /resume 时,则沿用该宿主对既有切换入口的守卫口径:插件端与「历史对话」按钮一致(忽略选择并提示、不切换),桌面端维持其多会话并行语义(可切走,后台会话不被中断)。
  9. 假设选择器已打开,当用户按 Esc 或点击选择器外部时,则关闭选择器,当前会话、消息与输入框草稿保持原样。
  10. 假设插件端(VSCE / JB)的「历史对话」按钮与 /resume 命令,当任一入口打开列表时,则两者打开同一个选择器、同一份列表(同一范围、同一搜索与选中行为);/resume 不得把列表范围扩大到工作区之外,也不允许出现两份范围不同的历史列表。(桌面端的选择器与侧边栏会话树采用两条刻意的来源,不受本条约束,见 desktop/desktop-sessions.md。)
  11. 假设磁盘上存在数百条会话,当选择器打开时,则列表在打开时按需读取并显示加载态,不阻塞输入框与当前对话;不因数量设隐藏上限(搜索用于缩小范围,而不是靠截断列表控制规模)。

用户故事:会话元数据头:真实创建时间与 git 分支(优先级:P1) ​

作为 wave -r 用户,我希望会话文件的 metadata header 持久化真实创建时间(createdAt)与创建时的 git 分支(gitBranch),以便列表能使用真实创建时间,并在多 worktree 场景区分同一仓库不同分支的会话。

为什么是这个优先级:SessionMetadata.createdAt 目前是列表扫描时即时生成的 new Date()(恒等于"当前时间",完全失真);多 worktree 场景的会话无法显示所属分支,只能靠目录名猜测。

独立测试:创建会话后检查 JSONL 首行为 metadata header 且含 workdir、createdAt、gitBranch 字段;列表返回的 createdAt 与 header 一致;非 git 目录创建会话时 header 不含 gitBranch。

验收场景:

  1. 假设创建新会话,当 session 文件写入时,则首行为 metadata header,含 workdir 与 createdAt(ISO 8601,会话创建时刻);目录为 git 仓库时含 gitBranch(git branch --show-current 结果),非 git 目录或执行失败时省略 gitBranch。
  2. 假设会话文件带 metadata header,当会话列表(当前目录 / 全项目 / worktree 模式)返回 SessionMetadata 时,则 createdAt 取 header 中的真实创建时间,而非每次扫描生成当前时间。
  3. 假设会话文件带 gitBranch,当 wave -r 选择器渲染会话行时,则行尾显示该分支标签(如 [main]);无分支信息时不显示。
  4. 假设旧版会话文件(无 header 或 header 缺 createdAt),当列表返回时,则 createdAt 回退为列表生成时的当前时间(legacy 文件无创建时间记录,行为不劣化)。
  5. 假设git 命令执行失败或超时(非 git 目录、git 不可用、目录被删除),当创建会话时,则 header 省略 gitBranch,创建流程不中断、不报错。

用户故事:会话自定义标题(重命名)(优先级:P2) ​

作为用户,我希望给会话起一个自己的标题,而不是永远显示首条用户消息截断出的那 30 个字符,以便在会话列表里一眼认出某段对话。

为什么是这个优先级:标题目前完全不可改(全仓无重命名入口),想换个说法只能重开会话;而它是会话列表里唯一的辨识信息。属于体验增强而非阻塞性缺陷,故 P2。存储侧参照 Claude Code:标题作为保留条目追加进会话自己的 JSONL 文件(custom-title),而不是另建索引文件——这样标题随会话文件走,任何读该文件的宿主(CLI wave -r、GUI /resume、插件端头部)都能拿到,无需额外的同步通道。

独立测试:对一个已有会话重命名后,检查该会话 JSONL 末尾多出一条 {"type":"custom-title","customTitle":"…"} 保留条目,而 metadata header 与既有消息行逐字节不变;列表返回该会话的 customTitle;继续追加超过 64KB 的消息,分别验证「正常退出」「压缩」「重新恢复该会话」三条路径之后再次列表,标题仍为自定义标题而非首条消息;再对已有标题的会话执行 /rewind,验证重写后的文件末尾仍带 custom-title 条目。

验收场景:

  1. 假设用户对某会话设置自定义标题,当宿主持久化时,则必须向该会话自己的 JSONL 文件追加一条保留条目 {"type":"custom-title","customTitle":"<标题>","sessionId":"<会话 ID>"}——不得改写 metadata header(保持「创建时写入一次、永不重写」的既有契约)、不得改动任何既有消息行、不得为此新建旁路索引文件。

  2. 假设会话文件带 custom-title 条目,当 listSessions / listAllSessions(当前目录 / 全项目 / worktree 三种模式)返回 SessionMetadata 时,则必须带上 customTitle 字段。

  3. 假设会话既有自定义标题又有首条用户消息,当渲染会话标签时,则显示优先级必须为「自定义标题 > 首条用户消息截断 30 字符 > 新对话」;只有自定义标题缺失时才回退到截断规则。

  4. 假设会话文件在末尾带 custom-title 保留条目,当加载该会话(恢复 / /resume / 全量线程读取)时,则该条目不得作为消息出现在对话流中,也不得参与 lastActiveAt(末条消息时间)、latestTotalTokens(尾窗 token 扫描)与首条消息提取——读取侧的每个入口都必须显式跳过全部保留条目类型(metadata、custom-title)。

  5. 假设会话已重命名,当出现下列任一「会话收尾 / 会话接续」时机时,则若该会话存在自定义标题,必须把它重新追加到文件末尾(重追加前必须先做一次尾窗读刷新,吸收其它进程——CLI 选择器、SDK 宿主——刚写入的更新值,再落内存持有值):

    1. 正常退出:Agent.destroy() 收尾时,且必须在 saveSession() 之后独立成步(saveSession() 无新消息会提前 return,挂在其内部会被跳过)。对应 Claude Code 的 registerCleanup(sessionStorage.ts:458)。
    2. 压缩结束:compactMessagesAndUpdateSession 追加完压缩块之后。一个挂点同时覆盖手动 /compact 与请求前自动压缩两条入口(二者共用 aiManager.compactConversation)。对应 Claude Code 的 compact.ts:711 与 :1057 两处。
    3. 接管恢复:restoreSession 成功加载目标会话之后。对应 Claude Code 的 adoptResumedSessionFile(sessionStorage.ts:1533)——让标题在恢复的当下就回到 EOF,而不是等这次会话再退出;没有这一步,「恢复一个标题已滑出尾窗的旧会话」在列表里仍看不到标题。
    4. 切换会话(切走):restoreSession 保存并结束当前会话的那一步(interactionService.ts 发 SessionEnd("resume") 的同点)——语义等同该会话的收尾。
    5. /rewind 之后:rewriteSessionFile 是整文件重写、只序列化消息,会物理丢掉末尾的 custom-title 保留条目,重写后必须补回标题。wave 独有——Claude Code 的 rewind 是 checkpoint 式、不重写转录文件。(同一处还会丢掉 metadata header,那是与标题无关的既有缺陷,本轮只补标题,header 的丢失另行跟踪,见边界情况。)

    不做「首次物化」时机(Claude Code 的 sessionStorage.ts:983):CC 在该点补写的是 mode / agentSetting 这类「启动期已确定、文件尚未创建」的字段,而 wave 的自定义标题在 renameSession 时就立即写盘,不存在「内存有、盘上无」的窗口;将来新增启动期字段时再补。也不做 /clear:它生成新的会话 ID,标题属于旧会话、不应继承。

    函数命名对齐 Claude Code 的 reAppendSessionMetadata;字段本轮只含 custom-title,形状按「进程持有的保留条目集合」预留扩展,不提前引入无生产者的字段。改名的即时可见由「改名即追加到当时 EOF」保证;活会话期间标题被消息推出尾窗的窗口期是刻意取舍,见边界情况。

  6. 假设会话尚无 transcript 文件(仅有 meta 消息、尚未物化),当用户重命名时,则必须先物化该会话文件再写入标题(重命名是用户显式动作,不受「meta-only 会话不落盘」约束);后续消息写入同一文件。

  7. 假设用户提交的标题 trim 后为空,当保存时,则必须什么都不做——不发请求、不写条目、不改变已有标题(空串不是「清除标题」的信号)。

  8. 假设写盘失败(权限不足、磁盘错误、远端不可达),当重命名结束时,则必须让调用方感知失败,以便界面把已乐观上屏的新标题回滚为原标题——不得静默留在「看起来成功了」的状态。

  9. 假设旧会话文件不含 custom-title 条目,当列出与渲染时,则行为必须与现状完全一致(回退首条用户消息截断),不报错、不补写。

  10. 假设会话文件不存在或中段损坏,当重命名或列出时,则沿用既有容错口径(缺失视为不存在、损坏跳过),不得因自定义标题引入新的崩溃路径。


用户故事:会话重命名入口:CLI 与 IDE 插件(优先级:P2) ​

作为 CLI / IDE 插件用户,我希望就地给当前会话或列表里看中的那条会话改标题,以便不必重开会话就能把某段对话标成自己认得出的名字。

为什么是这个优先级:与桌面端侧边栏同时做(三端共用同一份存储与显示优先级)。CLI 侧照 Claude Code 的两条腿:/rename 命令 + 选择器内就地改名(Ctrl+R);插件端入口只能是头部标题本身(面板没有会话列表 UI,唯一的标题展示点就是头部)。桌面端的侧边栏行菜单见 desktop/desktop-sessions.md。

独立测试:CLI 内输入 /rename 我的标题 后检查会话文件末尾多出 custom-title 条目、头部与 wave -r 列表都显示新标题;在 wave -r 选择器里对某条会话按 Ctrl+R 改名后按 Enter,验证该行立即显示新标题;在插件端点击头部标题改成新名字,验证标题即时变化、重启 IDE 后仍是新标题。

验收场景:

  1. 假设用户在 CLI 输入 /rename <标题>,当命令执行时,则当前会话的自定义标题被写为 <标题>(trim 后),并给出成功提示;命令不得要求先退出进程、不得改变会话 ID 或消息历史。
  2. 假设用户只输入 /rename(不带参数),当命令执行时,则给出一条用法提示(如 Usage: /rename <title>)并保持原标题不变——本轮不做「无参数则用模型自动生成标题」(与 desktop/desktop-sessions.md 非目标「AI 自动生成标题」一致)。
  3. 假设当前会话正在生成回复(加载中),当用户输入 /rename <标题>,则命令照常执行(改名与生成互不影响),不中止当前回合。
  4. 假设 wave -r 或会话内 /resume 的选择器已打开,当用户对聚焦的那条会话按 Ctrl+R,则该行的预览区变为标题输入框(初值为该会话当前标题,见 desktop/desktop-sessions.md 的显示优先级),列表本身不关闭、不丢失范围与选中位置。
  5. 假设用户正在选择器内改名,当按 Enter,则保存新标题并退出编辑(回到普通列表模式,该行立即显示新标题);当按 Esc,则退出编辑并回滚为原标题(不提交、不关闭选择器)。
  6. 假设选择器内提交的标题 trim 后为空,当按 Enter 时,则什么都不做(不发请求、标题不变、退出编辑);重命名失败(文件缺失/不可写/远端不可达)时给出可见失败提示、标题保持原样。
  7. 假设选择器列出的是非当前会话(其它目录/其它 worktree/其它项目的会话),当用户对它改名,则写入该会话自己的会话文件(按其所属项目目录定位),并同步更新该行的显示——不得因为「不是当前会话」而拒绝,也不得误写到当前会话的文件。
  8. 假设插件端(VSCE / JB)聊天面板头部显示会话标题,当用户点击标题,则标题就地变为输入框(自动聚焦、内容为当前标题并全选),不弹出模态对话框、不改变面板布局、不清空对话。
  9. 假设用户正在头部行内编辑,当按 Enter 或点击输入框之外(blur),则保存新标题;当按 Esc,则回滚为原标题;当提交内容 trim 后为空,则什么都不做。行内形态不提供 Save 按钮,也不在保存期间禁用对话。
  10. 假设用户使用中文/日文等输入法,当处于组合输入中(isComposing),则不得把 Enter / Esc 当作保存 / 取消处理;假设焦点在标题输入框,当用户按键,则按键不得冒泡触发宿主快捷键。
  11. 假设标题保存成功,当插件端面板重载或 IDE 重启,则头部必须仍显示自定义标题(标题随会话文件持久化,不依赖界面内存状态)。
  12. 假设同一条会话先由 CLI 改名、再在 IDE 插件或桌面端打开,当任一宿主显示其标题时,则必须显示同一个自定义标题——三个宿主共用同一份存储(会话文件里的 custom-title 保留条目)与同一套显示优先级,不得各存一份。标题被消息推出尾窗的窗口期内允许列表侧暂时回退为首条消息(见边界情况的刻意取舍),该会话被恢复或其宿主正常退出后必须回到自定义标题。

边界情况 ​

  • 权限问题:如果无法创建或写入会话目录,系统应优雅地失败并给出清晰的错误提示。
  • 损坏的文件:如果 JSONL 文件包含无效 JSON,在列出时应跳过,在加载时应视为不存在。
  • 空会话:没有消息的文件应使用文件的修改时间作为 lastActiveAt 时间戳。
  • 元数据消息:当会话文件尚未物化(无已保存消息)且所有未保存消息均为 isMeta: true(如 SessionStart hook 上下文)时,跳过持久化;meta 消息保留在内存中,随第一条真实 user/assistant 消息一并写入。
  • 路径编码冲突:使用哈希后缀确保长路径即使被截断也具有唯一性。
  • 会话标题为空:IDE 插件会话首条消息缺失或为空时,标题回退为"新对话"。
  • 流式期间新建对话:IDE 插件在 AI 响应流式输出期间禁用"新建对话"按钮,防止中断中的会话被意外清空。
  • 后台任务运行期间新建对话/加载历史:IDE 插件(VSCE/JB)在当前会话存在正在运行的后台任务(类型 shell/subagent/workflow、状态 running,消息实时推送至 webview)时,与流式输出期间同等对待——"新建对话"按钮禁用、历史会话选择被忽略,防止原地清空/替换会话使正在运行的后台终端工具、后台子代理或后台工作流被意外中断或与 UI 脱离;任务结束后守卫自动解除。桌面端为多会话并行模型("新建对话"创建独立会话、切换会话不中止后台会话),不适用本守卫。
  • 会话 ID 重复:同一 sessionId 出现在多个项目目录时,聚合列表按最后活跃时间取最新(见"跨目录会话聚合列表"故事)。
  • 非 git 目录:git worktree list 失败时按"无 worktree"处理,Ctrl+W 不生效。
  • 长路径解码兜底:项目目录名被哈希截断时,全项目模式展示编码名兜底;恢复仍按目录内文件定位,不受展示影响。
  • 剪贴板不可用:跨项目提示的 cd 命令复制失败时仍显示命令文本,用户可手动复制。
  • worktree 目录已删除:选中的会话属于已删除的 worktree 时按"其他项目"处理,给出 cd 命令提示而非报错。
  • 选择器语义单一来源:同一份会话在 /resume 与 wave -r 两处入口必须落在同一个恢复类别(原地 / 同仓库 worktree / 跨项目),范围切换后的列表内容与快捷键提示也必须一致;不允许两处各自实现导致行为漂移。
  • 会话内切换的落盘目录:切换会话必须使会话 ID 与落盘项目目录同步变化。只换 ID 不换目录会把目标会话的后续消息写进当前目录,造成同一 sessionId 在两个项目目录各有一份文件。
  • 连续切换:会话内 /resume 可重复执行,含 worktree ↔ 原目录、worktree ↔ 另一 worktree;不得因为"当前已处于上一次切换后的目录"而拒绝再次切换。
  • 切换后的目录派生缓存:项目记忆、规则与目录相关的系统提示段在切换后必须失效重建。沿用旧目录的缓存会让模型读到另一个项目的上下文,且不会有任何报错。
  • worktree 会话状态不随会话恢复:会话记录只持久化创建时的工作目录,未持久化 worktree 会话状态(分支、是否由 hook 创建等)。因此切换后只对齐工作目录,worktree 会话状态不恢复——这是刻意取舍,不是缺陷。
  • 读文件缓存不随会话恢复:切换后不重建"本会话已读文件"的缓存(既有 gap,本轮不做),模型可能重复读取已读文件。
  • 无消息的当前会话:切换前若当前会话尚无任何消息,不因切换时的自动保存而产生空的会话文件。
  • metadata header 向后兼容:createSession 不传 metadata 时仍写空文件(旧调用方不变);读取侧缺 header 的 legacy 文件走 decodeSync/当前时间回退。
  • metadata header 只读:header 是 append-only 文件的首行,创建后不重写;gitBranch 为创建时刻快照,不随后续分支切换更新。唯一会整文件重写转录的是 /rewind:它不修改 header 内容,但必须原样保留首行(连同 custom-title 条目),见 ui/rewind-command.md。
  • gitBranch 为 per-session 数据:同一 workdir 不同时间创建的会话可能在不同分支,gitBranch 按会话文件逐一读取,不做目录级缓存。
  • GUI 列表单一来源:GUI 的 /resume(以及插件端「历史对话」)与 CLI 的 wave -r 必须读同一份磁盘会话来源——不允许 GUI 依赖界面自己的会话索引,否则 CLI 或其它客户端创建的会话不可见(桌面端现状即为此问题)。"同一来源"不等于"同一范围":范围为各宿主的既有语义(插件端=工作区目录、桌面端=全部项目与主机、CLI=当前目录及其 Ctrl+W/Ctrl+A 展开)。
  • 插件端范围不扩大:插件宿主无法切换工作区,因此 /resume 的列表范围必须与头部「历史对话」按钮一致——不得因"统一为全部项目"而列出工作区之外的会话(跨项目恢复属于桌面端与 CLI 的能力)。
  • 桌面端侧边栏索引语义不变:/resume 的磁盘来源只服务选择器本身,不改动侧边栏会话树与状态看板沿用会话索引的既有语义(被 /resume 恢复的索引外会话登记进索引,见 desktop/desktop-sessions.md);两条来源并存是刻意设计,不是待清理的重复。
  • 选择器形态按宿主既有语言:插件端沿用面板头部下拉弹窗(与"历史对话"按钮同一组件、同一位置);桌面端用居中模态对话框,且必须渲染在应用根层——桌面端分屏时根 ChatApp 不挂载 chatContainer,挂在其中的浮层在分屏下会打不开(既有教训)。
  • 桌面端会话范围随当前主机:桌面端 /resume 只列"当前对话所属主机"磁盘上的会话(本地对话列本地、远程对话列那台主机),与登录态、最近目录、会话索引的既有"当前主机"语义一致;不做跨主机聚合。切换主机/焦点分屏后再次打开选择器即为该主机的列表。
  • 桌面端主机的两种失败要区分:当前主机不可达时给出可见失败提示(不得静默显示空列表,否则用户会误以为没有历史会话);"目录确实不存在"(ssh test -d 判定)才走目录缺失提示,探测失败不得据此判定(见 desktop/desktop-sessions.md 的「远端主机不可达时不得删除会话」)。
  • 不做会话存活检测(本轮不做):会话在 wave 中没有锁,同一段会话可能正被终端或另一个进程使用;GUI 选择器不检测「该会话仍在别处打开」,也不因此拒绝切换,多点同时追加写入的风险留待独立议题(/resume 只负责切换)。
  • 不做外部会话的信任确认(本轮不做):从磁盘发现的会话不按"外部导入"要求二次确认,选中即切换。
  • GUI 选择器不提供 fork:选中即继续该会话本身,不复制为新会话(--fork-session 类语义不在本轮)。
  • 保留条目是 SDK 私有契约,读取侧必须逐个入口跳过:会话 JSONL 里除「带 timestamp 的真实消息」之外的行都是保留条目。全量读取、末条消息、尾窗 token 扫描、首条消息提取每个入口都必须跳过全部保留类型——漏一处就会把条目当成消息(lastActiveAt 变成 Invalid Date、列表排序错乱,且不会有任何报错)。
  • 重追加是稀疏触发,活会话窗口期内列表可回退(刻意取舍):自定义标题落到文件末尾的时机只有五个——①改名时(追加到当时 EOF);②正常退出(Agent.destroy());③压缩结束;④接管恢复(restoreSession 加载完成后);⑤/rewind 重写后(完整清单与理由见验收场景 5,对齐 Claude Code 的 reAppendSessionMetadata)。其余时间继续追加消息会把标题行逐渐推出 64KB 尾窗(一轮超大工具输出即可直接推出):窗口期内其它宿主(另一终端 wave -r、GUI /resume)列出该会话会回退为「首条用户消息截断」,持有该会话的宿主不受影响(标题在内存);进程被硬杀(未走退出收尾)同样落在这个窗口里。这是对齐 Claude Code 稀疏触发的刻意取舍,换取每次保存不再多付一次 64KB 尾窗读 + 一行标题追加;该会话下次正常退出 / 压缩 / 被恢复后重新追加,标题回到 EOF 自愈。
  • 不做列表侧 head 兜底(刻意取舍):不照抄 Claude Code 的 head 64KB 扫描(listSessionsImpl.ts:97-103 的 tail || head 双窗)——head 兜底只对「改名发生在文件尚小于 64KB 时」的会话有效,却要给列表里每个长会话文件多一次头部读。代价:「早期改名 + 标题已滑出尾窗 + 尚未自愈」的会话在列表里也回退首条消息(Claude Code 靠 head 兜底能救这一部分)。
  • 恢复会话必须读回标题并在当场追加回末尾(自愈的来源):恢复路径整读会话文件时必须收集 custom-title 作为内存值,并在加载完成后立即重追加到 EOF(验收场景 5 的第 3 项,对齐 Claude Code 的 adoptResumedSessionFile)——否则标题一旦滑出尾窗,退出时既没有内存值、尾窗也读不到,标题将永久退回首条消息;而且在本次会话退出之前,其它宿主在列表里也一直看不到标题。有这一环,任何「滑出」都只是窗口期而非数据丢失(文件里的条目 append-only 永不删;/rewind 是唯一物理删除内容的路径,删的是被回退的消息、保留条目原样留下,它仍按验收场景 5 第 5 项把标题重追加到 EOF,见 ui/rewind-command.md)。
  • /rewind 重写保留文件级保留条目(含 metadata header):重写不重建文件,而是逐行筛选原文件(见 ui/rewind-command.md),首行的 {"type":"metadata",...} 与既有 custom-title 原样保留、按 RESERVED_ENTRY_TYPES 注册表搬运,故重写后 readMetadata() 仍返回创建时的 workdir / createdAt / gitBranch、readCustomTitle() 仍读得到;标题另按验收场景 5 第 5 项重追加到 EOF,保证列表在其尾窗里看得到。
  • 重命名不改变会话身份:会话 ID、文件路径、创建时间、列表排序(按最后活跃时间)与 worktree 归属均不受重命名影响。
  • 标题长度上限 200 字符:自定义标题在写入边界(UI 输入 + 宿主校验)截断/拒绝到 200 字符,与同仓其它对象(项目/头部会话名 200)一致——不照抄 Claude Code 的「会话标题不设限」。
  • 不做 AI 自动标题(本轮不做):不引入 ai-title 保留条目、不额外调用模型;没有自定义标题时继续用首条用户消息截断兜底。因此 CLI /rename 不带参数时不生成名字,只提示用法。
  • 不做全局快捷键:不照抄 Claude Code 的 ⌘/Ctrl+⌥+R——CLI 只认选择器内 Ctrl+R(与既有 Ctrl+A/Ctrl+W 同族),GUI 只认鼠标入口。
  • 插件端历史弹窗列表不提供改名入口:插件端的改名入口只有聊天面板头部的标题本身(面板没有常驻会话列表 UI);桌面端的入口是侧边栏会话行菜单 + 头部标题。/resume 与「历史对话」列表内的改名只在 CLI 侧提供。
  • 改名不引入派生能力:不做按标题搜索排序、批量改名、标题历史/撤销。