<p align="center">
  <img src="icon.svg" width="56" alt="">
</p>

<div align="center">

# dsh-session-manager

[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness)

</div>

DSH 的会话管理插件：把工作区与会话**搬到新目录**，把会话**导出 / 导入**成 `.dshsess` 包，以及用
**WebDAV 在多台机器之间同步**会话。

[中文](README.md) | [English](README.en.md)

在 DSH 里，「会话属于哪个工作区」不是可改字段，而是由会话日志 header 里的 `cwd` 推导出来的：
宿主既没有 move / reassign API，也不接受项目目录与 `cwd` 不一致的日志。所以手工搬目录，轻则会话变成
Ungrouped，重则加载时报 `corrupt session log`。本插件把「改 header cwd + 移动日志目录 + 让宿主当场认下新的归属」
三件事一起做，每次写盘前都会先摆出这份计划、事后能逐字节回滚。（为什么必须这样，见
[.agents/notes/implemented/](.agents/notes/implemented)。）

同步走的是同一条路：远端只放 `.dshsess` 包，拉取时按配置里的映射把 `cwd` 改写成**这台机器**的路径，
所以两台机器的同一个项目处在不同目录也能互通。

## 它能做什么

| 入口 | 适合什么 |
|---|---|
| 设置里的「会话管理」页 | 日常使用：在「会话」分页里逐条归档、重算名称或删除，或者勾上要搬的会话（可以跨目录，源目录被删掉也照旧）、点动作行上那枚「迁移」在弹窗里选目标目录；在「传输」分页里导出 / 导入；在「同步」分页里按 WebDAV 配置同步；在「备份」分页里回滚或恢复 |
| 5 个模型工具 | 直接跟会话说「把这个工作区的会话搬到 `~/dev/xxx`」或「跟远端同步一下」，由模型先预演再落盘 |

两个入口的迁移编排是同一份代码，所以弹窗里说会迁移几条，实做就是几条。

页面上的会写盘的动作（删除 / 迁移 / 导入 / 同步 / 回滚）都是**一个按钮开一个确认弹窗**：弹窗里摆的就是
那份只读计划（哪些会话、落到哪、备份落在哪、有什么问题），按「确认」才落盘，按「取消」则什么都不发生。
模型工具那一路仍然是 `plan` / `apply` 两次调用——模型自己先看清楚再落盘。

## 安装

从 npm 装（推荐）：

```bash
# 用宿主自己的插件命令：它会替你建/填充 profile，并维护依赖与锁文件
npx @deepseek-ai/dsh@next plugin --profile desktop add @he0119/dsh-session-manager
# 然后重启 DSH
```

开发时装本目录（`lib/` 不进 git，所以先构建）：

```bash
pnpm install && pnpm run build
npx @deepseek-ai/dsh@next plugin --profile desktop add /path/to/dsh-session-manager
# 然后重启 DSH
```

装完重启 DSH，**设置** 的左侧导航里会多出一页「会话管理」（排在官方那几页之后）。
本包**不支持** `github:` 形式的安装：仓库把构建放在 `prepublishOnly`、`lib/` 不入库，git 装法拿不到产物。
宿主版本要 0.2.0-rc.1 及之后的 0.2.x（`engines.dsh` 与两个 DSH peer 都是 `^0.2.0-rc.1`，
`@deepseek-ai/cordis` 另写 `^4.0.4`）。宿主**按 peer 范围决定装不装**：范围收不下运行中的那个版本，
整条 bundle 会被跳过（日志里是 `skipping profile bundle … is incompatible with dsh …`）。它判范围时带
`includePrerelease`，所以同一 minor 线上的新 rc（`0.2.1-rc.1`）照旧收得下；换 minor 线
（`0.3.0-rc.1`）时要跟着改这一行。

## 使用

### 设置 → 会话管理

每个要等一会儿的动作都会**报进度**：迁移、删除、回滚、导入（预演与落地）的弹窗正文里，归档与打开页面
那次整库扫描的原地，写的是「正在改写日志 8 / 253」+ 一条进度条 + 当前那一条的标题。分母是**这一段真正
会做的条数**（跳过的那些不算进去，否则条永远走不满）；没有分母的段落（读远端索引、写工作区注册表）只说在做什么、不画条。落地那一段里弹窗标题从「将要迁移」换成「迁移中…」，取消键
禁用——中途撒手会在库里留下半截状态。唯一的例外是**导出**：它的响应体就是那个包，没有"第几条"可讲。

**会话**——对着整个会话库逐条管理

- 列表**按目录分组**，组头可折叠（「全部收起 / 全部展开」在列表上方），组头那一下就是
  整组勾选——"把这个旧项目的会话都归档 / 都删掉"因此是一次点击。组头只写着**这一组的名字**：注册表里的
  工作区名称，没加入工作区的目录就取**项目身份**（有 git remote 时用它的最后一段，如 `dsh-session-manager`；
  没有才退回显示本机路径），所以行里不再重复归属那一列，省下的宽度留给标题；**项目身份与本机路径都在
  名字那一格的悬浮提示里**（两行：身份在上、路径在下——同一个仓库在本机的两个克隆靠路径区分）；
  有项目身份的目录还在名字后面挂一枚**主机名小标签**（`github.com`、`git.hehome.xyz`），扫一眼就知道
  哪些目录是认得出身份的仓库，标签自己的悬浮提示给全整条身份。
  落在侧边栏「未分组」那一组里的会话在行上挂「未分组」小标签
  （组头说的是目录，标签说的是侧边栏把它放在哪儿）。**子智能体会话缩进到父会话的下一级**（跟着它、排在
  它后面；父会话在别的目录组里时留在自己组里缩进并标明父在哪儿），"这条属于谁"不必读标签猜。每一行
  显示标题、字节数与创建时间；宿主侧边栏看不见的会话**也在这里**（那边
  点不到它们），行上挂标签说明原因：「子智能体」/「空白」/「已归档」，还活在宿主内存里的那条挂「活动中」；
- **筛选**：一排小胶囊——子智能体 / 空白 / 已归档 / 未分组 / 活动中，各带库里的条数；勾几枚看几类
  （多选＝任一命中），「全部」一键清空；下面一行是**标题 / id 搜索框**（"按 id 指名道姓"与"按标题
  回忆上次那个问题"都常用，所以两个都搜）。筛过之后头部报「显示 N / M 条」，**「全选」选的是眼下列
  出来的这些**（筛出空白再一键全选就是要删它们），旁边常驻一枚「清空」——它清的是**整个勾选集**，所以
  勾了一部分时也清得掉；勾选不会因为切换筛选而丢；
- **归档 / 取消归档**：勾选后一键收起或放回。走宿主自己的归档能力，**即时生效**——侧边栏马上跟着变，
  不必重启；勾父会话会连它的子智能体一起归档（族是一个单位，理由见下面删除那节）；宿主没提供这个
  服务（非 Web profile）时按钮置灰并说明原因；
- **重新生成名称**：勾选后一键请宿主的名称服务按**整段对话**重算名称。历史会话常常停在"首句前几个
  词 / 40 字节"的兜底名称上（含网址的首句会被切成半截链接），而名称 provider 只在"又有新的用户消息"
  时才修订一次——这一枚按钮就是把它补上。逐条报进度（每条要整份解一次日志再跑一次模型调用），算完
  之后分四类说清楚：**改了** / **算过但没变** / **跳过** / **失败**。跳过不是失败，它是"本来就不该动
  它"：**你手动改过名的**（重算会覆盖你起的名字）、**子智能体会话**（名称在侧边栏里看不见）、还没有
  用户消息的、从没有过主模型请求的（没有可用的模型路由）。宿主没有名称服务时按钮置灰并说明原因。
  **"你手动改过名的"那批想一起算**：接口（`POST …/retitle`）里带 `includePinned: true` 就放行——
  界面刻意不给这个开关，默认语义就是"别动用户的名字"（一次性清理"名称是脚本或导入写下的"那些会话时用得上）；
- **看内容**：鼠标停在某一行上，行尾就出现一枚「内容」（它顶掉那一行的时刻；触屏上一直摆着），点开
  就是这条会话的**完整内容**——日志逐条解析成时间线：人类
  消息、模型回答、思考（默认折起）、工具调用与结果、系统消息，以及其余一切事件（压缩、权限、审批、
  重试…连名称是怎么定下来的都在）。按轮次分节，点节标题就把这一轮折起来（不属于任何
  一轮的会话级事件自己一段，那段没有标题可折，所以不折）。工具结果自己不带工具名，视图按
  `callId` 从调用那条补上；一条事件
  一行，`seq` 会跳号所以只当参照物。**分页**（一次 80 条起，滚到底自己往下接），单格文本超长时如实
  标出"截断了 N 个字符"并给一个「查看全文」；读不出来的坏行照实说有几条、在第几行，其余事件一条不少。
  顶上按种类筛选（我 / 模型 / 工具 / 其它）。这一页**不经过宿主**，所以侧边栏看不见的那些（空会话、
  孤儿目录、已归档、子智能体、导入的会话）一样能看；
- **删除**：勾选 → 点「删除所选」，弹窗里列出会被删的每一条、日志数与备份位置 → 确认。删除**先备份到本插件的
  备份根，再删掉整个会话目录**；删完侧边栏要等宿主重新扫描才会少掉那几行。还活在宿主内存里的会话
  会被拒绝——**重启 DSH** 让宿主放手，之后别先点开它再删（Web 端没有单条关闭的动作，见「说明」）。
  想反悔就去「备份」分页里点「恢复」；
- **子智能体跟着父会话走**：勾一条父会话，它的子智能体会话（连同更深的那些）会一起删——弹窗里逐条列出
  并标明"随父会话删"，一个备份装下整族（父日志一没，子会话在侧边栏里就再没有入口，留在盘上等于
  变成看不见的残留）。**子智能体不能单独删 / 单独归档 / 单独导出**：它的勾选框是灰的（悬浮提示里写着
  该勾哪条父会话），直接调接口也会被拒并指名父会话——只删它会在父会话日志里留一条指着不存在会话的
  条目。父会话已经不在库里的孤儿例外：没有可跟随的会话，它只能单独收拾。**分叉出来的会话不算**：
  它是自洽的一条普通会话（源会话的历史已经拷进它自己的日志），父删掉它照样能打开，所以不带走。

**迁移**——把勾中的会话搬到一个目录

- **迁移就在「会话」分页的动作行上，源是你勾的那些会话，可以跨目录**：整库本来就是那一页的列表（同一套
  按目录分组、行上的标签、类别胶囊与搜索框，勾选也一样），**没有单独的迁移分页**。所以"项目文件夹被删掉、
  项目重建到别处之后把会话迁过去"就是：在列表里勾上那几条（它们在侧边栏里落在「未分组」那一组，那只是
  它们的标签），点动作行上那枚「迁移」，在弹窗里选重建后的目录——一次搬完，不必按目录各来一次；
- **目标目录在弹窗里选**：那个字段与「传输」页的落地目录是**同一个控件、同一份候选**（值控件点开候选
  面板，也能手输路径或去文件系统里挑）。候选面板里一行两行字（名字在上，本机路径在下，整串在悬浮提示
  里），顶上那个**筛选框**按路径、工作区名称或项目身份收窄列表——它只筛候选、不改值；**点一行就是选中**
  （随即收起）。盘上已经没有的那些（工作区还登记着，目录被删掉或改了名）照旧列在名单里，但挂一枚
  「不存在」的标签、点不动：选它们只会撞上「目标工作区目录不存在」。有 git remote 的目录，
  值控件里那一行是**项目身份 + 本机路径**
  （`github.com/he0119/dsh-session-manager — /home/uy_sun/dev/dsh-session-manager`）：这枚控件只有一行
  可用，路径不能从这儿消失——同一个仓库在本机的两个克隆只能靠它区分。目标目录必须已存在；旁边是可选的新建
  工作区名称与是否连带**会话创建过的文件**。目标一选好就有一份只读的预演（见下面那条），改目标会重取
  一份；
- **源目录不必还在盘上**：清理文件夹时把项目文件夹删掉之后，会话日志还留在库里、header 里的 `cwd`
  也还是那个路径，勾上它们、目标选重建后的目录就迁过去了。从前那种"先选一个源目录"的做法在这里反而没有
  落点：那批会话可以散在好几个目录里，而一次迁移只有一个目标，只能按目录各来一次；
- **勾得动的是"这一页能收拾的"，搬得走的是"侧边栏看得见的"**：清单里唯一勾不动的是挂在父会话下面的
  子智能体会话（勾选框是灰的，悬浮提示点名该勾哪条父会话）——空白与已归档的那些在这一页照旧能勾，归档
  与删除本来就该收下它们。**迁移那一侧因此自己把隐藏的摘出去**：宿主对点名的隐藏会话一律拒（工具那一路
  点名它们时，计划会如实列出这条拒绝），所以页面只把看得见的那些送进计划，弹窗里多一句"另有 N 条勾中的
  会话这次不搬"并写明下一步（已归档的取消归档、空白的删掉、子智能体连着它的父会话一起迁）——说会搬
  几条就搬几条，不会因为混进一条而整次落空。它们因此**搬不走**，而归属还在源工作区里，那块工作区就不会
  被删——预演与结果会点名"还剩哪几条、因此不会被删"（工具返回值的 `stranded`）；**登记了、盘上却已经没有的**那些（插件删除会话时刻意不清注册表留下的悬空登记）不算成员：
  宿主本来就不认它们，判"这块工作区搬空了吗"时也不再数它们，并在这一次里顺手从注册表摘掉；
- **宿主正攥着的会话搬不动**：宿主内存里那条（插件列表里带「活动中」标签的那些）还没放下旧路径，挂靠
  会被它回绝，日志也不能搬走——计划会把这批**摘出去**，弹窗里多一句"另有 N 条宿主持在内存里，这次不
  搬"，它们留在原目录、归属不变。**重启 DSH 之后再迁一次**即可把剩下那几条收进来；点名点到它们、
  或勾中的每一条都还活着时，计划如实拒绝（不是"没选中会话"）；
- **子智能体跟着父会话走**：勾一条父会话，它的子智能体会话（连同更深的那些）**一起搬**（归档与导出同样：
  勾父会话就把子智能体一起归档 / 一起装进包）——各自的日志
  header 改写 cwd、各自的会话目录搬进目标项目目录，一个备份装整族（否则族会被拆到两个目录里，而
  子智能体本身是搬不动的：它自己勾不动，点名它又是向上的牵连）。**分叉出来的会话不算**（它自洽、
  父搬走也照样能打开）。搬完成员资格不变：子智能体本来不算工作区成员，搬完还是不算；计划里会报「其中 N 条是
  子智能体会话」；
- **点「迁移」开弹窗**，里面是：目标目录那一个字段（以及新建工作区名称、是否连带产物）＋逐条列出会被
  搬走的会话（子智能体跟着走的那几条缩进一级、挂「随父迁」）、会话数、日志数、字节数、来源项目目录 →
  目标项目目录、**注册表会怎么变**（新建还是复用目标工作区、写进注册表几条、从哪些工作区搬出、是否移除
  已空的工作区、顺带摘掉几条悬空登记）、产物计划与跳过原因；搬不走的（宿主持着的、侧边栏不显示的）各多
  一句说明——它们留在源工作区里，那块工作区因此不会被删；
- **按「确认迁移」**：改写每个日志 header 的 `cwd`（只动首帧，其余字节不变）→ 搬会话目录 → 写回注册表 →
  **交给宿主自己改**（复用 / 新建目标工作区、把会话挂过去、摘空就删，见下面「注意事项」）→ **独立复核**
  （等价于宿主的 corrupt 判据，另加一条"会话 id 不变"的复核）→ 留下字节级备份；完成后页面会告诉你这次
  改动是不是即时生效，或者**需要重启 DSH**。

**传输**——把会话带走，再带回来

- 列表里的会话显示的是**标题**（鼠标悬浮才给出完整标题与 id）：uuid 对人是零信息，认会话靠的是
  「我上次问那个问题的会话」；读不到标题的老会话回落显示 id。导出列表与「会话」页照单全收、
  连侧边栏不显示的会话（子智能体 / 空白 / 已归档）也列出来——"这条会话要不要带走 / 要不要删"是人的
  决定，替你预先藏起来只会让"我明明有这条会话"变成找不着的谜；迁移则相反（见下）；
- **导出**：勾选会话 → 浏览器下载一个 `.dshsess` 包。包里是这些会话**所有代次日志的原始字节**
  （逐条带 sha256），加上日志里引用到的**图片 / 文件对象**——它们在日志里只是一串内容寻址的 id，字节
  平时放在本机的附件库里（`$DSH_HOME/attachments/`），不带上的话换台机器打开就全是「图片加载失败」
  （同一个对象被多条会话引用只装一份；本机库里找不到的会在导出后那句报告里说清有几个没带走）。不含会话
  创建过的普通文件。列表**按目录分组**（组名是工作区名称；没加入工作区的目录
  拿这个目录的项目身份最后一段当组名——有 git remote 就是它，没有才退回显示路径，两种情况都标出来；
  身份与本机路径都在组名那一格的悬浮提示里，认得出身份的还挂一枚主机名小标签），
  **组头那一下就是整组勾选 / 取消**——「把这个工作区的会话都带走」因此是一次点击；
  组头最前那个尖角是**折叠**（收起只把组内的行收起来，组头与"这组几条 / 勾了几条"都还在），列表上方那行
  「按目录分组」后面是**全部收起 / 全部展开**：收起是显示上的事，「全选」照旧按列出来的那些算，
  不会因为点了箭头就悄悄少导出几条；
  两级一眼分得开：组头是带底色的横幅 + 文件夹图形，会话行缩进挂在它下面、挂对话气泡图形（会话名称
  是用户写的一句话，很容易长得像个目录名，靠读字分不出来）。子智能体会话再往里缩一级、挂在它父会话
  下面（列表里的父子与"删 / 搬父会话会带上谁"是同一棵树）；侧边栏「未分组」那一组里的会话在这里
  跟着目录走，行上挂一枚「未分组」小标签说明这件事。
- **筛选**：与「会话」页同一套——一排小胶囊（子智能体 / 空白 / 已归档 / 未分组 / 活动中，各带库里的条数，
  多选＝任一命中，「全部」一键清空）加一个**标题 / id 搜索框**；两者一起用时是"与"（搜 foo 且只看空白）。
  筛过之后头部报「显示 N / M 条」，**「全选」选的是眼下列出来的那些**（旁边常驻的「清空」清的是整个
  勾选集）；筛空的那一组整组不画
  （组头底下没有行，看着像坏了），组头报的也是筛剩下的条数；
- **导入**：选包 + 选目标目录——那一个字段与迁移的目标目录**同一个控件、同一份候选**（值控件点开候选
  面板，也能手输路径或去文件系统里挑）→ 点「导入」，弹窗里逐条列出会创建什么、`cwd` 会被改写成什么、哪些会被
  跳过、注册表会怎么变，包里还带着附件对象时再多一句"有几个、其中几个本机没有" → 确认才落盘（全是跳过
  时确认按钮是灰的，**只有附件对象要补**时是例外：那种情况下按下去就是"把缺的图补上"，会话一条都不动）。
  对象与日志都从包里落进本机——**先落对象、再写日志**，所以不会出现"会话已经是库里的一员、图还是空的"
  那一瞬间；对象落不了会单独提醒，不影响会话导入。导出不问：它只打包下载，不改本机任何
  东西。导入**永不覆盖**：库里已有同 id 的会话只跳过并报告；包里没有 `cwd` 的
  会话落 `_no-cwd` 项目目录，也不进注册表。

**备份**——那几个会写盘的操作共同的后悔面：列出本插件写过的每一份备份（时间、**迁移** / **删除** /
**覆盖前**、会话数、源 → 目标），点「回滚」/「恢复」先在弹窗里摆出动作清单，确认才动。迁移留下的那份点
**回滚**：把会话目录、日志字节与工作区注册表一起还原（空掉的目标项目目录也会删掉，与迁移清理空源项目
目录对称）。删除与同步覆盖本机那份留下的点**恢复**：只把备份里那份搬回原位——那两个操作从头到尾没碰过
注册表。

**说明**——把名词与代价一次讲清：分类词典（可见 / 子智能体 / 空白 / 已归档 / 活动中 / 未分组，其中「未
分组」就是侧边栏那一组：不属于任何工作区、而且侧边栏会显示它）、每页做什么、数据从哪来（会话库与
注册表两条路径），以及常见疑问（「未分组」到底算哪些会话、删完侧边栏为什么还在、迁移什么时候要重启、
活动中的会话怎么关掉、备份与「回滚」「恢复」的分工、子智能体跟着父会话走、包里有什么）。动作页（会话 /
传输 / 同步 / 备份）上只留"当下要做的决定"，那些词条与边界条件都在这页上，所以那几页每段说明都不超过
两行。

宿主没有 `webServer` 服务时（例如只用工具的前端）这一页不出现，工具照常可用。

页面右下角写着这一页是**哪个构建**：发布版只有版本号（`v0.3.1`）；从仓库直接 build 出来的产物多一个
短 commit（`v0.3.1 · 0b8c452`），构建时工作区有未提交改动再挂 `-dirty`。悬浮提示把这句话说全（是发布
版还是本地构建）。

### 同步（WebDAV）

多台机器上的同一个项目往往装在不同目录（`/home/alice/dev/proj` 与 `/opt/work/proj`），所以**不能**直接
同步会话库：库里的目录名与 header 的 `cwd` 绑死，换台机器就对不上。这一块的做法是把远端当中转——
远端放的是 `.dshsess` 包，落地仍然走导入那条路，由它把 `cwd` 改写成这台机器的路径。

**附件对象（图片与文件）走远端一个共享目录**：`dsh-session-manager/attachments/objects/<sha 前两位>/<sha>`
（文件对象在 `file-objects/`，两者与机器格同级）。包本身只有日志，所以推送时只补远端缺的那些、拉取时只
取本机缺的那些——同一张图不会因为「一条会话一个包」被反复上传（自动推送每写完一轮就重推一次），也不会
因为两台机器各推一份而存两份。手动点「同步」时还会多跑一趟**补对象**，专治计划按日志指纹判「两边一样、
谁也不用动」的那些会话：本机缺的就从共享库取回来（早先拉过来、图一直挂着的那批），远端缺的就传上去（旧
版本只推过日志的那批）。远端也没有的那些报一句「本机有 N 个附件对象在远端共享库里也没有，这次补不上」。
宿主没挂 `dsh-attachment-local` 时这一整块不参与：包与索引与升级前逐字节一致。

配置（插件配置里的 `sync` 块）：

```yaml
sync:
  url: https://dav.example.com/dsh        # WebDAV 地址（服务器根也行）；插件在它下面自建 dsh-session-manager/
  machineId: robot-a                       # 可选，缺省取主机名；两台机器别用同一个 id，也不能叫 attachments
  username: alice                          # 可选（Basic）
  passwordRef: DSH_DAV_PASSWORD            # 可选：**凭据引用名**（环境变量名）；留空就用 DSH_DAV_PASSWORD
  mapping:                                 # 显式映射：远端 cwd → 本机目录（两台机器各写各的）
    /home/alice/dev/proj: /opt/work/proj
  timeoutMs: 30000                         # 可选
  autoPush: true                           # 可选：自动推送（写完一轮就推、只推不拉，见下面那一段）
  autoPushIntervalMs: 300000               # 可选：兜底间隔毫秒，缺省 300000（5 分钟），最小 60000
```

这一节**也能在界面里改**：设置 →「会话管理」→「同步」分页（这张表单在卡片里），URL、机器名、账号、
密码、超时与映射表都能直接编辑（留空的字段按框里那份灰字生效：机器名留空就是主机名，超时留空就是
30000 毫秒），按「保存」写进 profile 那份配置文档
（`~/.dsh/profiles/<name>/cordis.patch.yml`）。`sync` 是活字段，改完**不用重启**就生效；三个路径字段
（`sessionsRoot` / `registryPath` / `backupRoot`）仍然只在配置文件里。路径映射在表单里**逐行增删**：
左边是远端记下的 cwd，右边是这台机器上的目录；远端那一栏必须逐字对上别的机器记下的路径，所以同步过
一次（弹窗里那份计划见过它们）之后它会把这些 cwd 列成候选，不用手抄。

**自动推送——只推不拉**：勾上它，插件就**在本机写完一轮时**自己推一轮（宿主那边一条会话的
`turn/end` 到了就动手），**只把本机的会话推到远端，一个字节都不拉**：不拉取、不覆盖本机那份、不备份、
不写注册表，也就不会动本机任何一条会话。这是一条安全边界，不是"跑得轻一点的同步"——拉取那一侧要动
本机已有内容（远端更新时先备份再顶掉本机那份），那件事得有人看着，界面上它前面还挡着一个确认弹窗，
所以后台这一路只做纯增量的那一半。两栏在「同步设置」表单里填（超时下面）：

```yaml
sync:
  autoPush: true                             # 打开自动推送（缺省不推送）
  autoPushIntervalMs: 300000                 # **兜底**间隔毫秒，缺省 300000（5 分钟），最小 60000（1 分钟）
```

推的时机是**最后一个会话事件之后安静 10 秒**：一轮对话不是只发一次 `turn/end`（自动续轮、子智能体起停
都会各发一次），先攒一攒，所以一串事件只会推一次，不会把整包连发好几遍。这一路绕开下面那道速度闸——
你刚写完一轮，"距上次才过了 20 秒"不该挡住它。

`autoPushIntervalMs` 是**兜底间隔**，不是"每 5 分钟才推一次"：它管的是事件层够不到的那三类——别的机器
推上去的东西（本机一个字节都没写，也就不会有事件）、本机删了会话要收尾索引（同样没有新事件）、事件层
没接上（DSH 崩过、没拿到会话服务）。所以它的意思是"最长多久没推就一定补一次"。小于 60000 的值会被抬到
60000（想的是"勤快点"不必报错，但也不让一个 0 把远端打爆）；间隔改完**不用重启**，下一轮就按新值走，
关掉也是立刻停。**远端更新的那些不会被拉下来**：它只推，所以别的机器的改动要到你手动点「同步」、在
弹窗里确认那一步才会落本机。卡片底部那枚「**立即推送一次**」（和「同步」同一行，开关开着时才出现）就是
当场跑一轮——它也绕开速度闸，因为你按的那一下不该被"距上次才过了 20 秒"挡住；它仍然只推不拉，所以没有
确认弹窗，进度画在页面横幅上。这一趟跑没跑、为什么会没跑（没开 / 兜底那轮还没到点 / 上一轮还在跑 /
没有要推的）都在横幅里说清，连续失败也会报一句。

**密码在那张表单里直接填**：那是个只写的输入框，按「保存」把密码写进宿主机凭据库
（`$DSH_HOME/.credentials.yaml`），配置文件里始终只有引用名——值不回显，界面只报「已配置 / 未配置」。
引用名不在这张表单上：它就是配置里的 `sync.passwordRef`（缺省 `DSH_DAV_PASSWORD`），要换名字（或者
本来就用环境变量）在插件配置页那份通用表单里改。启动环境里已有的同名变量会遮住写入，这时界面把密码
框标成不可改。

**配完先点一次「测试连接」**：它只读探一次远端（两次 PROPFIND，不改远端任何东西），回的是一句结论
——连得上/认证过不过/地址对不对，认证失败还会分清是「没填用户名」「引用名里没有值」还是「服务器不认
这套用户名密码」。测的是**已保存的**配置（宿主的运行时按配置现读），所以有未保存的改动时按钮是禁用的。
写权限不在这里验：第一次同步自然会建那一层，测试不该往别人的服务器上放东西。

**多机共用一份配置**：远端索引里每条会话还记着**项目身份**——仓库的 git remote（规范化成
`host/owner/repo`）与会话 cwd 在仓库根之下的相对路径。拉取的时候先用本机的候选目录（会话的 cwd +
注册表里记着的工作区路径）认一遍身份，认出同一个仓库就落 `本机仓库根 + 相对路径`。于是几台机器可以
用**字面一样**的配置（`url` 相同、`machineId` 缺省取主机名、`mapping` 留空），同一个仓库克隆在
`/home/alice/dev/proj` 与 `/opt/work/proj` 也照样对得上；反过来，两台机器上同名的目录若是两个仓库，
不会被认成同一个项目。没装 git、不是仓库、没有 remote、本机没这个项目——一律退回下面那张映射表，
计划里会点名是哪个仓库没认出来。

`mapping` 与身份**共存**：显式映射优先（配了就按配的落），身份是自动那条路。所以非 git 目录、
或者想强制落到别处，仍然靠映射。

远端布局：插件在 `url` 下面自建一层自己的命名空间，`dsh-session-manager/<machineId>/index.json` 是
这台机器贡献了哪些会话，`dsh-session-manager/<machineId>/<id>.dshsess` 是一条会话一个包——所以 `url`
可以直接填 WebDAV 的服务器根或账号根，不必自己写这一层。**一机一格**：WebDAV 没有锁，每台机器只写
自己那一格、读别人的全部，就不会互相盖掉。

在「同步」分页：点「同步」先在弹窗里算一份只读计划（读远端，什么都不写），报出「会拉取 N 条 / 会推送
M 条」，三张表（会拉取 / 会推送 / 两边都有、这次不动）各自**按项目目录分组**——组头是这一组的名字（工
作区标题，没加入工作区就是项目名）+ 一枚主机名小标签 + 这一组几条，项目身份与本机路径在悬浮提示里；行里只
剩动作、会话名与大小，会话名后面跟着**与会话列表同一套**的类型标签（子智能体 / 空白 / 已归档 / 活着
的，本机已有的那几条才挂得上）。「这次不动」那张表的第三列是哪台机器持有的另一份。按「确认同步」才真
的拉取与推送，而确认用的是**预演算出来的那份数据**（不再把同一份计划重算一遍）：只有预演之后本机或远端
又变了才会从零重算，那时成功那句话里会说明；落地那一段弹窗正文换成进度条与「正在推送 12 / 84」，下面是
当前那一条的标题——分母是**这一段真正会做的条数**（跳过的那些不算），拉取与推送各自走一遍。规则与边界：

- **两边都有同一个 id 时，按内容与「最后活动时间」择新**：内容逐代一致（cwd 之外）就不动；本机确实
  是远端那份的前缀就重推刷新远端；远端领先（本机这份是它的前缀）或两边各自写过而远端更晚，就**先备份
  本机那份、再换成它**（旧的那份随时能从「备份」页里恢复）；有一边读不到最后活动时间
  （老索引 / 宿主没挂投影缓存）或者两边一样新，就两条都不动，报告里说清是「远端更新」还是「两边各自
  写过」。「最后活动时间」取宿主从日志内容里折出来的两枚钟里**晚的那枚**（最后一次提问、最后一条消息
  ——agent 自己写进去的也算，所以"推送之后本机又跑了几轮"照样分得出高下），复制、改写 cwd 都不影响
  它。判据是代次指纹，
  **与 cwd 无关**——落地一定会改写别人的 cwd（库目录名与 header 的 `cwd` 绑死），按字节比会把拉取来的
  那份判成"两边各自写过"，连"同步下来继续写"都无法再推送回远端；
- **本机严格领先时重推**：两边共有的代次内容一致（cwd 之外逐条一致），说明远端那份确实是本机这份的
  前缀，刷新它不会丢任何代次；同一 id 有多台贡献时，拉取的一侧取**领先**的那份而不是格子名排前面的
  那份；
- **空白会话不参与同步**：建出来但一个回合都没聊过的会话不推送（正文里报一句「跳过 N 条空白会话」），
  下一次推送还会把它从自己那格索引里撤下来——别的机器因此不再看得到它；本机这条是空白而远端有内容时
  以远端为准（空的那份没什么可保护的）；
- 删掉的会话下一次推送就从自己那份索引里消失（远端那份包不主动删），别的机器已取走的副本不受影响；
- 没有 `cwd` 的会话不硬塞路径，按 `_no-cwd` 落地（与导入同一条口径）；没配映射的 `cwd` 会被跳过并列出来；
- 拉取来的会话要宿主重新扫描才会出现在侧边栏：改完注册表之后插件把活儿交给宿主自己做（与迁移同一套
  语义），宿主上没有那套动作时才提示重启——那时**确认弹窗里就会先说**，落地之后页面上再单独留一条警告
  （宿主自己接得住时一个字都不说）。

同步也要跑 `plan` 那一套：工具 `sync_sessions` 默认只预演，`apply:true` 才落盘。

### 模型工具

| 工具 | 写盘 | 说明 |
|---|---|---|
| `plan_session_migration` | 否 | 只读计划：会话/文件数、目标项目目录、注册表变更、阻塞问题 |
| `migrate_sessions` | 需 `apply:true` | 默认 dry-run；执行前做字节级备份，事后自动复核 |
| `rollback_session_migration` | 是 | 按备份目录字节级回滚 |
| `verify_workspace_sessions` | 否 | 复核某项目目录内日志与 header 的一致性 |
| `sync_sessions` | 需 `apply:true` | 与 WebDAV 远端同步（拉取别人推送的、推送本机独有的；两边都有同 id 时按内容与最后活动时间择新）；默认 dry-run |

## 注意事项

- **改完注册表交给宿主自己做，正常不必重启**：磁盘上的 `workspace.json` 只是第三份拷贝——宿主内存里那份
  才是权威（写入才改它，绝不从磁盘重读），它另有一份启动时建的 header 索引。所以插件改完注册表之后不是
  "等宿主自己发现"，而是把活儿交给宿主自己做：先让它按磁盘重看一眼（刷新 header 缓存与"这条会话住哪儿"
  的索引），再复用 / 新建目标工作区、把会话挂过去、把源侧摘空、删掉已空的工作区。这几件事宿主自己会落盘、
  也会通知界面，所以侧边栏立刻按新归属显示——不动进程，也不杀正在跑的回合。会话 id 全程只被挂靠与摘除，
  一个都没变；已存在的工作区也是复用自己那条记录，id 不变。
  宿主上没有那套动作时（只有工具没有界面的前端、老版本），工具与页面都会如实说 **需要重启 DSH**，并且
  **重启前别改任何工作区**——新建 / 改名 / 归档都会用内存副本把这处改动覆盖掉。宿主**中途拒绝**时也
  一样：插件会把这次算好的注册表**整份写回文件**（宿主只要动过手就已经拿内存副本盖过一次），所以重启
  后看到的是这次的结果，不是它那份旧归属。
- **落地之后顺手把列表元数据折好**：侧边栏那一行的标题、空白判据与最后活动时间都不来自日志，而是宿主自己那份
  投影检查点——它只在**活着的**会话上被写。所以导入、同步拉取、迁移（改写了 `cwd`）落地之后，插件会借宿主自己的
  两个服务（读完整日志 + 冷读投影）把这些会话各补一次，侧边栏因此不用逐条点开就有名字。代价是每条落地会话多读
  一遍日志；读不动的逐条跳过并如实报条数，宿主上没有那套动作时那几条要等你第一次点开。
- **先看不写**：工具与页面上的计划（弹窗里那份）都不写任何字节；每次真正写盘前都会先留一份字节级备份。
- **同步会按「谁更新」择新**：远端那份更新（或两边各自写过而它更晚）时会**先备份本机那份、再换成
  它**；有一边读不到最后活动时间就两条都不动（见「同步」那一节的边界）。
- **只动首帧**：改写只重新压缩 header 那一帧，其余 frame 字节原样保留，回滚可逐字节还原
  （连同可选搬迁的会话产物）。
- **路径**：会话 `cwd` 与注册表路径都不带结尾斜杠；`projectKey` 会把 `/`、`\`、`:` 折叠成 `-`，
  极少数路径会因此编码碰撞，这种情况计划层会直接拦下。
- **插件配置**：`sessionsRoot` / `registryPath` / `backupRoot` 三个可选字段可覆盖上述默认路径；
  `sync` 块配 WebDAV 同步（`url` / `machineId` / `username` / `passwordRef` / `mapping` / `timeoutMs`）。
  密码只放**引用**（环境变量名），配置文件里没有明文：界面上直接填的密码进的是宿主机凭据库
  （`$DSH_HOME/.credentials.yaml`），不是这份配置。

## 文档

- [.agents/notes/](.agents/notes/AGENTS.md)——每条决策的依据与被放弃的做法（中文）：多帧 zstd 的静默
  丢数据、启动不变式、有损编码的碰撞、`.dshsess` 容器的取舍、**为什么包要带上附件对象**、生效模式、
  同步怎么判「谁更新」、空白会话为什么不搬，以及界面上的那些取舍
- [docs/internals.md](docs/internals.md)——决策地图：按主题索引到上面那些笔记
- [docs/development.md](docs/development.md)——本地流程：依赖、构建、测试、开发实例
- [docs/releasing.md](docs/releasing.md)——发版流程

## 许可证

MIT
