oh-my-pi 会话切换与最近会话列选完全指南:从 --resume 解析到运行时 switchSession 的底层机制
【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi
本文是 oh-my-pi(Coding agent with the IDE wired in)会话子系统的一线技术指南,围绕 docs/session-switching-and-recent-listing.md 展开,系统讲解最近会话发现、--resume/--continue目标解析、会话选择器(TUI)以及运行时会话切换的完整链路。读完你将掌握:会话文件如何按 cwd 分桶存储、两类列表管线(轻量欢迎视图 vs 完整恢复列表)的区别、终端面包屑(breadcrumb)如何决定--continue的目标、启动期 resume 与进程内 switch 两条路径的差异,以及各种失败与边界场景的行为约定。文中所有结论均可在 packages/coding-agent/src/session 下的源码与测试中验证。
一、实现全景:本主题涉及的源码文件
会话切换与最近会话列选不是一个孤立的函数,而是一条横跨存储、列表、CLI、TUI 组件与运行时控制器的完整调用链。以下文件按职责分类,全文将反复引用:
| 职责 | 文件 |
|---|---|
会话存储与导航核心(SessionManager) | packages/coding-agent/src/session/session-manager.ts |
会话列表扫描与轻量元数据(SessionInfo/RecentSessionInfo) | packages/coding-agent/src/session/session-listing.ts |
| 磁盘布局、目录编码与终端面包屑 | packages/coding-agent/src/session/session-paths.ts |
| 单个会话文件的加载 / 迁移 / blob 解析 | packages/coding-agent/src/session/session-loader.ts |
CLI 全屏选择器(selectSession) | packages/coding-agent/src/cli/session-picker.ts |
TUI 会话列表组件(SessionList) | packages/coding-agent/src/modes/components/session-selector.ts |
交互式选择器控制器(SelectorController) | packages/coding-agent/src/modes/controllers/selector-controller.ts |
| 启动参数解析与 resume 编排 | packages/coding-agent/src/main.ts |
运行时会话切换核心(AgentSession.switchSession) | packages/coding-agent/src/session/agent-session.ts |
关于磁盘布局、文件格式与条目(entry)类型的基础知识,请先阅读姊妹文档 docs/session.md,本文默认你已经了解"会话 = JSONL 文件,一行一个条目"这一前提。
二、最近会话的发现机制
2.1 目录作用域:按规范化 cwd 分桶
SessionManager默认把文件会话存放在"规范化 cwd"对应的桶目录下:
~/.omp/agent/sessions/<encoded-cwd>/<timestamp>_<sessionId>.jsonl其中<encoded-cwd>是对规范化 cwd 做路径编码后的目录名(详见 docs/session.md):
- 家目录下的相对路径 →
-<relative>(如-work-proj); - 临时根目录下的相对路径 →
-tmp-<relative>; - 其余绝对路径 →
--<encoded-absolute>--。
编码前会先做路径等价解析(resolveEquivalentPath),因此 symlink 别名与真实路径会共享同一个桶——这保证了"同一个项目不管从哪个别名进入,都能看到同一批会话"。
在 session-paths.ts 的computeDefaultSessionDir中,每次解析目录还会执行两类最佳努力迁移:
- 17.2.5–17.2.8 短暂使用的哈希桶方案(
<scope>-<project-basename>-<sha256(canonical-cwd)>,已于 17.2.9 回滚)被迁移回路径编码名(修复 issue #7677); - 更早的
--<home-encoded>-*--拼写被合并进新的-*格式。
SessionManager.list(cwd, sessionDir?)(session-manager.ts)只读取解析出的那个桶,除非显式传入sessionDir覆盖。
2.2 两条负载不同的列表管线
代码中并存着两套列表实现,它们读取的数据量和产出完全不同:
管线一:getRecentSessions(sessionDir, limit)—— 欢迎页 / 摘要视图
定义在 session-listing.ts:
- 每个文件只读取4 KiB 前缀;
- 同时理解当前"定宽标题槽(title slot)"文件和旧式"头部优先(header-first)"文件两种格式;
- 解析出 header + 最早的用户文本预览;
- 返回轻量
RecentSessionInfo(path、name、timeAgo三个字段); - 按文件
mtime降序排列。
它刻意不做全目录内容扫描(避免数千个会话时耗时数百毫秒):先列文件、按 mtime 排序,然后仅对最新的limit个文件解析名字。名字优先查history.db标题索引,未索引的旧文件(分支/复刻副本、legacy 会话)才回退做逐文件 header 扫描,且扫描出的标题会回填索引,下次启动即可跳过读取。
管线二:SessionManager.list(...)/listAll()—— resume 选择器与 ID 匹配
- 每个文件读取4 KiB 前缀 + 有界的 32 KiB 尾部(不读完整 JSONL 主体);
- 构造完整
SessionInfo(path、id、cwd、title/parent 元数据、创建/修改时间、大小、消息预览与计数、生命周期状态); - 前缀解析 + 消息标记计数用于列表文本,尾部解析用于推导最终消息的生命周期状态;超出前缀窗口的后续消息不会出现在
allMessagesText中; - 状态枚举
SessionStatus为complete/interrupted/aborted/error/pending/unknown(session-listing.ts); - 按
modified降序排列;SessionManager.list与listAll还会把置顶(pinned)会话排在最前(sortPinnedFirst)。
这两个常量在 session-listing.ts 中定义:SESSION_LIST_PREFIX_BYTES = 4096、SESSION_LIST_SUFFIX_BYTES = 32_768。并行度控制同样在此:文件数 ≤ 64 时单线程扫描,超过后按min(16, 可用并行度, ceil(文件数/64))启动有界并行 worker。
性能上还有一个值得注意的细节:扫描结果按stat 身份(mtimeMs + size)键控缓存在 4096 条目的 LRU 中。每次扫描仍会执行一次statSync(它就是失效检查),命中缓存则跳过打开 + 读取 + 解析。两个失效路径都被覆盖:流式追加会增大size;updateSessionTitle通过writeSync原地改写定宽标题槽,size不变但mtimeMs更新。不可解析文件的负结果同样会被缓存。
2.3 孤儿备份恢复与只读变体
正常按目录扫描前会先调用recoverOrphanedBackups(session-listing.ts):当主.jsonl文件缺失时,把 EPERM 原子改写回退路径产生的<basename>.jsonl.<snowflake>.bak中最新的一份恢复到主路径,防止崩溃于两次 rename 之间时把用户最后的好状态遗留在加载器视野之外。listSessionsReadOnly则是非变异变体,跳过这一修复步骤。
2.4 元数据回退行为
RecentSessionInfo(最近摘要)的显示名,由sessionDisplayName(session-listing.ts)按优先级决定:
title(显式标题);- 第一条用户消息;
- 兜底标签
Untitled · <time>。
原始id被刻意永不使用——它是 UUID,对用户不友好且与相邻会话无法区分。欢迎屏按可用列宽截断渲染名(不设固定长度)。无论标题还是消息派生的名字,都只保留第一行,并剥离控制字符(sanitizeSessionName,session-listing.ts)。
SessionInfo列表条目的字段回退:
title:优先定宽标题槽值,否则header.title,否则前缀中能看到的最后一次压缩shortSummary;firstMessage:前缀中能发现的第一条用户消息,否则"(no messages)";- 选择器还展示:修改时间、文件大小、生命周期状态(
unknown除外)、fork 标记,以及 all-projects 作用域下的 cwd。
三、--continue的目标解析与终端面包屑
3.1 终端面包屑的写入与读取
面包屑文件位于:
~/.omp/agent/terminal-sessions/<terminal-id>内容为两行(原始 cwd + 会话文件路径),可选第三行fresh。writeTerminalBreadcrumb(session-paths.ts)是同步 + 最佳努力的:写入频率低(仅在会话创建/切换/重置时,绝非每次 append),且顺序很重要——懒创建的 fresh 会话一旦物化必须立刻被重打为非 fresh,异步乱序写入会导致已物化会话被误标为 fresh。readTerminalBreadcrumbEntry(session-paths.ts)返回TerminalBreadcrumb(cwd、sessionFile、exists、fresh);目标文件缺失时返回null,除非第三行是fresh——那是"JSONL 尚未落盘的懒会话边界",此时以exists:false返回,让调用方与真正失效的旧面包屑区分开。
3.2continueRecent的 7 步决议
SessionManager.continueRecent(cwd, sessionDir?)(session-manager.ts)按下述顺序解析目标:
- 读终端面包屑(
~/.omp/agent/terminal-sessions/<terminal-id>); - 验证面包屑:已物化目标可用;目标缺失仅在第三行是
fresh(懒创建的/new边界)时可用; - 缺失的 fresh 目标 → 开新会话,而不是回退到复活旧 transcript——这防止了"创建后未产出任何内容就退出"时,重启反而被拉回更早的会话;
- 解析过期的 subagent 面包屑到其交互式父会话:
resolveBreadcrumbToInteractiveRoot(session-manager.ts)会沿<dir>.jsonl存在的方向向上最多走 8 层,因为 subagent 会话位于父会话的 artifacts 目录(<parent>/<agentId>.jsonl),修复前的面包屑可能指向这样的子会话; - cwd 不匹配且旧 cwd 已消失、且当前位置又没有自己的会话时,把面包屑会话重新扎根到当前 cwd(
open+moveTo)——这是 worktree 移动/重命名后的恢复路径; - 否则使用 cwd 匹配当前目录的面包屑;cwd 不匹配时用当前桶中最新会话;
- 没有可用面包屑时,按 mtime 选最新文件;没有文件则新建会话。
面包屑写入失败非致命(best-effort)。
3.3-c <value>的归一化
当-c的唯一位置参数符合会话 id 形状时,它被归一化为显式 resume 目标;否则该位置文本继续作为--continue的初始提示词。
四、启动期 resume 目标解析(main.ts)
4.1--resume <value>:两种模式
createSessionManager(...)处理字符串型--resume:
模式一:路径型值(含/、\,或.jsonl结尾) 直接SessionManager.open(sessionArg, parsed.sessionDir)。
模式二:resume key 值走resolveResumableSession(...)(session-listing.ts):
- 先搜本地(当前 cwd 桶)会话,未命中再搜全局所有会话——除非自定义
sessionDir禁用了全局回退; - 匹配不区分大小写,接受:
id前缀、完整 JSONL 文件名前缀、文件名时间戳之后 id 后缀(sessionMatchesResumeArg,session-listing.ts); - 按 modified 降序取第一个匹配,没有歧义提示(多个会话共享前缀时取最新)。
cwd 失效处理:若匹配到的会话记录 cwd 已不存在,CLI 提示Move (re-root) it into the current directory? [Y/n]。接受则open后moveTo(cwd)迁移;拒绝则干净退出;非 TTY 无法应答,抛出SessionResolutionError(main.ts)。
跨项目恢复不 fork:否则会话在其记录的项目中打开——即使匹配来自全局,启动进程也会切换 cwd、重载项目级设置/插件、重新解析启用的模型,然后才构造 agent。它不会因为跨项目匹配就自动 fork 出一个新会话。switchToResumedProject(main.ts)负责这一"进程 cwd 提交 + 插件缓存重置 + 设置重载 + 模型重解析"的过程;其中任何一步失败都会尝试完整回滚到启动目录,回滚也失败则抛SessionResolutionError。
无匹配:抛出Session "..." not found.,并提示可用omp --resume(无参)从最近会话中选择,或直接omp新建。
4.2--resume(无值):选择器流程
无值--resume在初始 session-manager 构造完成后处理:
- 用
SessionManager.list(cwd, parsed.sessionDir)列当前目录会话; - 若为空,探测
SessionManager.listAll()——仅用于区分"全局全空"状态并预加载 Tab 作用域数据;选择器本身从不自动切到 all-projects 作用域(issue #3099); - 两个列表都空 → 打印
No sessions found并退出; - 打开全屏 TUI 选择器(
selectSession); - 取消 → 打印
No session selected并退出; - 选中 →
SessionManager.open(selected.path),然后switchToResumedProject把进程/项目级状态切到会话 cwd(setProjectDir、插件缓存重置、设置重载),并重新解析作用域模型。
4.3--continue
直接使用SessionManager.continueRecent(...),即第三节的面包屑优先行为。
五、选择器内部机制
5.1 CLI 选择器(src/cli/session-picker.ts)
selectSession(sessions, options)(session-picker.ts)创建全屏备选屏(alternate-screen)TUI,挂载SessionSelectorComponent,且恰好 resolve 一次:
- 选中 → resolve 所选
SessionInfo; - 取消(Esc)→ resolve
null; - 硬退出(Ctrl+C 路径)→ 停止 TUI 并退出进程;
- Tab切换当前目录 / 全项目作用域;all-projects 列表懒加载或由调用方预加载;
- 搜索:会话元数据/前缀文本 + 短暂 debounce 后的
history.db提示历史匹配(historyMatcher从HistoryStorage.open()构建,缺失/锁定时降级为纯会话内搜索,不阻塞选择器); - 鼠标滚轮切换选中行,左键点击直接选中;
- Delete(或空搜索时 Backspace)弹出确认后删除 JSONL 及会话 artifacts(
deleteSessionWithArtifacts)。
SessionPickerOptions提供allSessions、title、scopeLabel、showCwd、allowDelete、allowGlobalScope、historySearch、pinnedIds等控制项;外来会话导入选择器会关闭删除、历史增强与全项目作用域。
5.2 进程内交互选择器(SelectorController.showSessionSelector)
流程(selector-controller.ts):
- 用
SessionManager.list(currentCwd, currentSessionDir)取当前目录会话;即便目录为空,all-projects 列表仍保持懒加载; - 以全屏备选屏 overlay 形式展示
SessionSelectorComponent(通过ctx.ui.showOverlay,左上角锚定、全尺寸,底层 transcript 不被改动),并接入懒加载loadAllSessions、history.db提示匹配、删除、置顶标记; - 回调:
- select→ 锁定选择器输入,调用
handleResumeSession(sessionPath);成功后隐藏 overlay、恢复编辑器焦点;可恢复的切换前失败会解锁选择器并保持打开; - cancel→ 隐藏 overlay、恢复编辑器焦点、重渲染;
- exit→ 隐藏 overlay 后
ctx.shutdown()。
- select→ 锁定选择器输入,调用
/resume <id-prefix>命令先本地后全局解析并直接切换;而/resume @claude、/resume @codex打开的是只读源导入选择器:选中的外来 transcript 先持久化为 OMP 会话再切换,且这些选择器不提供删除、历史增强与全项目作用域。
5.3 会话列表组件行为(SessionList)
支持:
- Up/Down 与 Page Up/Page Down 导航(钳制边界、不循环);
- Enter 选中;
- 空搜索时 Delete 或 Backspace 触发确认删除;
- Esc 取消;Ctrl+C 退出;
- Tab 切换当前目录 / 全项目作用域;
- 全屏选择器中鼠标滚轮 / 点击;
- 多 token 搜索:覆盖 id/title/cwd/首条消息/前缀消息文本/路径——字面匹配按新近度优先,其次足够强的模糊匹配;
history.db的提示历史匹配在输入停顿后可能被提升到前面。
空列表渲染:
- 当前目录作用域 →
No sessions in current folder. Press Tab to view all.; - 全项目作用域 →
No sessions found; - 空列表下 Enter/Delete/Backspace 无操作,Esc/Ctrl+C 仍然有效。
六、运行时切换核心:AgentSession.switchSession
switchSession(sessionPath)是进程内切换的主路径。生命周期 / 状态迁移(13 步):
- 捕获前一文件,发出可取消的
session_before_switch(reason: "resume",含目标文件); - 断开 agent 监听器、中止活动工作、运行切换前 reconciler、flush 挂起的 bash/会话写入;
- 快照回滚状态(manager、队列、消息、模型/thinking/tier、工具/prompts、provider-cache 身份、checkpoint/rewind 状态),随后清空消息队列;
- 切换不同会话时,排干/分离 advisor 记录器;
sessionManager.setSessionFile(sessionPath):更新面包屑、加载/迁移/blob 解析/建索引、在 cwd 策略允许时采纳已记录的 cwd(session-manager.ts);- 同步会话 id、memory key、继承的 provider-cache key、显示上下文与 checkpoint/rewind 状态;
- 发出
session_switch、替换消息、重置 advisor 会话状态、同步 todos; - 切换不同会话时关闭旧 provider 会话;同会话重载但 replay 发生变化时同样处理;
- 按 role/default 回退顺序恢复第一个可用的已记录模型;
- 若加载的分支以被打断的工具流结尾,追加一条合成的 abort 消息并重建显示上下文;
- 恢复配置的 thinking(
auto保持为 auto)与各 family 的 service tier,无对应记录时回退到当前设置; - 按需重置 memory/工具会话状态、重连监听器、运行模式 reconciler、刷新 workspace 感知的基础系统提示词;
- 切换不同会话时恢复 advisor 成本、完成 bash 过渡、通知会话变更回调,成功返回
true。
返回false的场景:before-switch hook 取消,或 cwd 策略拒绝。跨项目切换时若没有 cwd 变更回调则直接拒绝——绝不静默采纳目标 cwd;回调拒绝同样视为取消。
失败恢复:快照之后的任何失败都会恢复之前的 manager 与运行时状态、重连并重新 reconcile、把 bash 过渡标记为失败,然后重新抛出。
七、交互式切换后的 UI 状态重建
SelectorController.handleResumeSession(selector-controller.ts)先调用switchSession:
- 若返回
false,选择器在任何新会话 UI 更新之前停止,保持现有会话与 UI 不变(可恢复的切换前失败会解锁选择器并保持打开); - 切换成功后才执行 UI 刷新:
- 停止加载动画;
- 清空状态容器;
- 清空 pending 消息 UI 与 pending 工具映射;
- 重置流式组件/消息引用;
- 若恢复会话的 cwd 与之前不同,把进程与 cwd 派生的缓存重新指向它(
applyCwdChange); - 清空聊天容器并从会话上下文重渲染(
renderInitialMessages); - 从新会话 artifacts 重载 todos;
- 显示
Resumed session(跨项目恢复时显示Resumed session in <dir>)。
也就是说,可见的对话与 todo 状态完全从新的会话文件重建。
八、启动期恢复 vs 进程内切换
8.1 启动期恢复(--continue/--resume/ 直接打开)
- 会话文件在
createAgentSession(...)之前选定; sdk.ts在创建期间构建既有会话上下文;- agent 消息与 replay 状态在构造期一次性恢复;
- 模型/thinking/service tier 使用持久化状态 + 当前配置回退;
- 交互模式随后 reconcile 持久化的模式状态。
8.2 进程内切换(/resume风格选择器路径)
- 使用已运行会话上的
AgentSession.switchSession(...); - 消息/模型/thinking/tier 与会话级运行时状态原地重建;
- 发出
session_before_switch/session_switch钩子(扩展与钩子系统分别在 packages/coding-agent/src/extensibility/extensions/types.ts 与 packages/coding-agent/src/extensibility/hooks/types.ts 注册这两个事件); - UI 聊天/todos 刷新;
- 交互模式 reconciliation 通过注册的会话切换 reconciler 运行。
九、失败与边界行为
9.1 取消路径
- CLI 选择器取消 → 返回
null,调用方打印No session selected,进程退出; - 交互式选择器取消 → 关闭 overlay,会话不变;
- 核心钩子或 cwd 策略取消 →
switchSession()返回false;交互式选择器在其 UI 刷新/状态路径之前停止,保留旧会话与旧 UI;无回调的跨项目切换被拒绝而非静默采纳目标 cwd。
9.2 空列表路径
- CLI
--resume(无值):只有当前目录且全局列表都空时才打印No sessions found并退出;否则空目录选择器会引导按 Tab; - 交互式选择器:空目录作用域渲染 Tab 提示且保持可取消。
9.3 目标会话文件缺失 / 无效
当打开或切换到具体路径(setSessionFile)时:
- ENOENT→ 视为空 → 在该精确路径初始化新会话并持久化;
- header 损坏/无效(或解析出的条目不可读)→ 视为空 → 初始化并持久化新会话。
这是恢复行为而非硬失败(session-manager.ts 中明确的显式空路径分支会立即物化 header)。注意区分:SessionManager.open对损坏 header会在#setSessionFile内抛错,而列表管线中的不可解析文件只是被跳过并缓存负结果。
9.4 硬失败
真正的 I/O 失败(权限错误、改写失败等)仍会抛出并传播给调用方。
9.5 ID 前缀匹配的注意点
- 匹配用小写化后的会话 id、小写化 JSONL 文件名、文件名时间戳之后的小写化 id 后缀做
startsWith; - modified 降序的第一个匹配胜出;多个会话共享前缀时没有歧义 UI;
- 前缀列表元数据刻意保持轻量,因此搜索文本可能不包含会话文件前 4KB 之外的消息——这正是 CLI 选择器额外叠加
history.db提示历史匹配的原因(session-picker.ts 的注释明确指出:这用来恢复 4KB 会话列表前缀永远看不到的提示词)。
十、实战速查
| 场景 | 推荐命令 | 目标解析路径 |
|---|---|---|
| 回到本终端上次的会话 | omp --continue | 终端面包屑 → 最老新鲜边界 → 最新文件 → 新建 |
| 按 ID/文件名前缀恢复 | omp --resume <key> | 本地桶 → 全局桶(大小写不敏感前缀匹配) |
| 恢复指定文件 | omp --resume <path> | 直接open(path) |
| 交互选择最近会话 | omp --resume | TUI 选择器(当前目录,Tab 切全项目) |
| 会话内切换 | /resume <id-prefix> | switchSession原地重建 |
| 导入 Claude/Codex 会话 | /resume @claude//resume @codex | 只读导入 → 持久化为 OMP 会话 → 切换 |
关键常量速记:会话列表前缀 4 KiB、状态尾窗 32 KiB、并行阈值 64 文件、最大 16 worker、扫描缓存 4096 条;session_before_switch可取消、switchSession返回false表示被拒;非 TTY 下 cwd 失效的 resume 抛SessionResolutionError。
本文所有机制均以当前仓库实现为准:列表管线细节见 packages/coding-agent/src/session/session-listing.ts,决议逻辑见 packages/coding-agent/src/session/session-manager.ts 与 packages/coding-agent/src/session/session-paths.ts,启动编排见 packages/coding-agent/src/main.ts。若你需要在多项目、多终端并发场景下调试"为什么--continue没回到我想的会话",优先检查~/.omp/agent/terminal-sessions/<terminal-id>面包屑内容与~/.omp/agent/sessions/下的桶目录归属——这两处几乎能解释全部 resume 行为。
【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考