Cross-workspace session resume
跨 workspace 会话恢复
`/resume` could only reach sessions started in the launch directory, so returning to yesterday's work in another project meant remembering its path, leaving the TUI, and relaunching there. Two independent causes produced that limit, and fixing either alone changes nothing. Storage was the binding one. The shipped TUI composition defaulted its persistence root to a relative `./.sessions`, so each launch directory owne
English
Problem
/resume could only reach sessions started in the launch directory, so returning to yesterday's work in another project meant remembering its path, leaving the TUI, and relaunching there. Two independent causes produced that limit, and fixing either alone changes nothing.
Storage was the binding one. The shipped TUI composition defaulted its persistence root to a relative ./.sessions, so each launch directory owned a disjoint JSONL root and a disjoint derived session-query.db. Sessions from another project were not filtered out of the listing — they were absent from the store the listing reads. The JSONL backend already partitions per-cwd inside one root, so the partitioning was doubled: once by root, once within it.
The picker then filtered again. It dropped records whose cwd differed from the current session before display, and summarizeResumeCandidate independently marked a differing cwd as disabledReason: 'different workspace', so a foreign session that did reach the store was both hidden and refused.
Finally, resume never changed directory. The host re-execs dsh --resume=<id> through process.execve, which inherits the cwd. Session header cwd is restored from the log, but process cwd is what dsh-fs-local, the bash executor, and glob/grep resolve against, so resuming a foreign session would have replayed its transcript while acting on the wrong project.
Decision
The shared CLI configuration supplies one session root under the Harness home, the picker gains a workspace scope, and the handoff carries the target directory.
Storage. The shared base owns the default in apps/cli/config/base.cordis.yml: its session-persistence-jsonl row calls the app-boot-provided dshHomePath('sessions'), which uses the canonical DSH_HOME resolver and its standard ~/.dsh fallback. TUI, Web, and headless therefore consume one default without a session-specific launcher patch or slot. An overlay or personal patch that states an explicit root replaces that row's whole config and remains the deployment's authoritative choice.
Scope, not exclusion. A workspace other than the current one is a display scope rather than a disabled reason. showResume() summarizes every record and the ResumePicker owns a scope of 'workspace' | 'all', defaulting to the current workspace so the common case is unchanged. Tab toggles; the scope line names the active scope and the count the other holds; each row in the all-workspaces scope reports its own workspace, and that label joins the searchable text only in the scope that shows it. A toggle clears the query and selection so the highlighted row always belongs to the visible list, and the per-row workspace line makes a row one terminal row taller in that scope, which the visible-count budget accounts for.
summarizeResumeCandidate therefore drops 'different workspace' and gains 'session has no recorded workspace'. That is a real new refusal rather than a rename: a header without cwd names no directory for the host to enter, so it cannot be handed off even though its log is intact.
Handoff. TuiResumeHost.handoff takes the target cwd beside the SessionId. preflightResume resolves both together and returns them, so the caller cannot re-derive a stale directory from the row it displayed — a record whose cwd moved between listing and preflight is resumed in the re-read directory, which is why the former "reject a moved cwd" behavior is now a handoff with the new path. The shipped host chdirs before disposing the app: an unreachable directory must reject while the caller can still restore the terminal, because after teardown no owner remains to report to. Resume always uses the default dsh --resume surface because meta rejects parent options; the handoff already enters the persisted target directory.
Alternatives considered
Patch persistenceRoot from the dsh launcher instead of changing the bundle default. Rejected after finding that a loader patch assigns config wholesale. The personal ~/.dsh/config.yaml overlay already patches the tui-agent row with a partial config, which is exactly why persistenceRoot was falling back to the bundle default in the first place; a launcher patch would either be erased by that overlay or have to win over it and make the overlay unable to set the field. Owning the default in the bundle survives any partial patch and keeps one home for the fact.
Keep ./.sessions and additionally scan the Harness-home root. Rejected: two roots means two SQLite indexes and a merged listing whose rows have different liveness and revision authorities, to preserve visibility of logs that the no-migration decision already gives up.
Migrate existing project-local logs into the shared root. Rejected by the requester. Sessions under a project's ./.sessions stay on disk and stay resumable by explicit dsh --resume <id> from that directory, but no longer appear in /resume.
One flat list of every workspace. Rejected: it loses the "this project" default that the overwhelmingly common case wants, and in a busy home directory the current project's sessions would compete with unrelated ones.
Let the host infer the directory from the restored session header. Rejected: the header is model- and prompt-facing state restored after boot, while the directory must be entered before execve. Passing it explicitly keeps the ordering visible at the seam.
Consequences
- Sessions already stored under a project-local
./.sessionsdisappear from/resume. This is the accepted cost of no migration. - A resumed session can change the process's working directory, so a foreign resume is not a pure transcript restoration — every path-resolving tool moves with it.
- The Harness home now holds session logs for every project on the machine. Its growth is no longer bounded by one checkout, and no retention policy is introduced here.
Testing
TUI tests cover the default scope hiding other workspaces while reporting their count, Tab revealing them with per-row workspace labels, Tab back clearing the query and selection, searching by workspace label, a cwd-less record staying visible but disabled, and the handoff receiving both the id and the workspace re-read at preflight. The former "reject a moved cwd" case now asserts the handoff carries the new directory. Built CLI PTY tests exercise the shared config default and the per-process derived query index. The keyless TUI snapshot pins both scopes of the selector, including the scope line, the per-row workspace lines, and the Tab hint in the footer. A manual cross-workspace resume verified at the process level that the replacement's working directory became the target workspace.
中文
Problem
/resume 只能触达在启动目录中创建的会话,因此要回到昨天在另一个项目里的工作,就得记住它的路径、退出 TUI、再到那里重新启动。造成这一限制的原因有两个,彼此独立,只修其中一个都不会有任何变化。
存储是那个决定性的原因。已交付的 TUI 组合把持久化根默认成相对路径 ./.sessions,于是每个启动目录都独占一份互不相交的 JSONL 根目录,以及一份互不相交的派生 session-query.db。来自另一个项目的会话并不是在列表中被过滤掉的——它们根本不存在于列表读取的存储中。JSONL 后端本来就会在同一个根目录内部按 cwd 分区,所以分区被叠加了两层:一层按根目录,一层在根目录内部。
接着选择器又过滤了一次。它在展示前丢弃 cwd 与当前会话不同的记录,而 summarizeResumeCandidate 又独立地把不同的 cwd 标记为 disabledReason: 'different workspace',于是一个确实进入了存储的外部会话既被隐藏,也会被拒绝。
最后,恢复流程从不切换目录。宿主通过 process.execve 重新执行 dsh --resume=<id>,而它会继承 cwd。会话头部的 cwd 会从日志中还原,但 dsh-fs-local、bash 执行器以及 glob/grep 解析路径时依据的是进程 cwd,所以恢复一个外部会话会在回放它的 transcript(文本记录)的同时,作用到错误的项目上。
Decision
共享 CLI(命令行界面)配置提供 Harness home 下的同一个会话根目录,选择器获得 workspace 范围,交接过程携带目标目录。
存储。 共享 base 在 apps/cli/config/base.cordis.yml 中拥有默认值:其 session-persistence-jsonl 配置项调用由 app-boot 提供的 dshHomePath('sessions'),该函数使用规范的 DSH_HOME 解析器及其标准的 ~/.dsh 回退值。因此 TUI、Web 与 headless 使用同一个默认值,无需针对会话的启动器补丁或 slot。若 overlay 或个人 patch 显式声明根目录,它会整体替换该配置项的 config,并继续作为部署的权威选择。
是范围,不是排除。 当前 workspace 之外的 workspace 是一种展示范围,而不是禁用理由。showResume() 汇总每一条记录,ResumePicker 持有一个 'workspace' | 'all' 的 scope,默认为当前 workspace,因此常见场景毫无变化。Tab 切换范围;范围行会说明当前生效的范围,以及另一个范围下的数量;在全 workspace 范围中每一行都报告自己的 workspace,而该标签只在展示它的范围里才加入可搜索文本。切换范围会清空查询和选中项,使高亮行始终属于可见列表;而逐行的 workspace 行会让该范围下的每一行在终端里多占一行,可见条数预算已经把这一点计入。
因此 summarizeResumeCandidate 去掉了 'different workspace',并新增 'session has no recorded workspace'。这是一条真正新增的拒绝理由,而不是改名:没有 cwd 的头部没有指明任何目录供宿主进入,所以即便它的日志完好也无法完成交接。
交接。 TuiResumeHost.handoff 在 SessionId 之外还接收目标 cwd。preflightResume 把两者一起解析并一起返回,因此调用方无法从它展示过的那一行里重新推导出一个陈旧目录——在列表展示与预检之间 cwd 发生了变化的记录,会在重新读取到的目录中恢复,这也是原先「拒绝发生变化的 cwd」的行为如今变成携带新路径完成交接的原因。已交付的宿主在 dispose(资源释放)应用之前切换目录:不可达的目录必须在调用方还能恢复终端时就拒绝,因为拆卸之后已经没有任何所有者可供汇报。恢复始终使用默认的 dsh --resume 接口,因为 meta 会拒绝父级选项;交接过程已经进入持久化保存的目标目录。
Alternatives considered
从 dsh 启动器给 persistenceRoot 打补丁,而不是改动组合包默认值。 在发现 loader 补丁会整体赋值 config 之后否决。个人的 ~/.dsh/config.yaml 覆盖层已经用一份局部配置给 tui-agent 那一项打了补丁,这恰恰就是 persistenceRoot 一开始会退回到组合包默认值的原因;启动器补丁要么会被该覆盖层擦除,要么必须压过它,从而让覆盖层再也无法设置这个字段。把默认值放在组合包里能经受任何局部补丁,并让这项事实只有一个归属。
保留 ./.sessions,并额外扫描 Harness home 根目录。 否决:两个根目录意味着两份 SQLite 索引,以及一份合并列表——其中各行的活跃状态与版本权威来源并不相同,而这一切只是为了保住不做迁移的决策本就已经放弃的那部分日志可见性。
把现有的项目本地日志迁移到共享根目录。 被需求方否决。项目 ./.sessions 下的会话仍留在磁盘上,从该目录显式执行 dsh --resume <id> 仍可恢复,只是不再出现在 /resume 中。
把所有 workspace 铺成一个扁平列表。 否决:这会丢掉绝大多数场景想要的「本项目」默认值,而在一个繁忙的 home 目录里,当前项目的会话会和无关会话争夺注意力。
让宿主从还原后的会话头部推断目录。 否决:会话头部是面向模型与提示词的状态,在启动之后才还原,而目录必须在 execve 之前进入。显式传递它能让这个顺序在 seam 处保持可见。
Consequences
- 已经存放在项目本地
./.sessions下的会话会从/resume中消失。这是不做迁移所接受的代价。 - 恢复一个会话可以改变进程的工作目录,因此恢复外部会话不是单纯的 transcript 还原——每个解析路径的工具都会随之移动。
- Harness home 现在保存着这台机器上每个项目的会话日志。它的增长不再受单个 checkout 约束,而本记录也没有引入任何保留策略。
Testing
TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝发生变化的 cwd」的用例现在断言交接携带新目录。构建后的 CLI PTY 测试会检验共享配置默认值与每进程派生的查询索引。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。