dsh-session-mng 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/01-design.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-session-mng 设计与实现说明
2
2
 
3
- > 对应版本 0.1.3,实测宿主 DSH 0.2.1-rc.3(Desktop `desktop` profile;0.2.1-rc.2 亦验证过)。本文面向代码评审与二次开发,记录可观察行为、事务边界、版本敏感点和已知限制。
3
+ > 对应版本 0.2.0,实测宿主 DSH 0.2.1-rc.3(Desktop `desktop` profile;0.2.1-rc.2 亦验证过)。本文面向代码评审与二次开发,记录可观察行为、事务边界、版本敏感点和已知限制。
4
4
 
5
5
  ## 1. 设计约束
6
6
 
@@ -52,6 +52,14 @@
52
52
  | `POST /delete/preview` | 删除范围预览(根 + 子代理后代 + 分支提示) |
53
53
  | `POST /delete` | `{sessionIds, confirm:true}` |
54
54
  | `POST /move` | `{sessionIds, workspaceId}` |
55
+ | `GET /overview?subagents=0\|1` | 首屏概览:工作区计数/归档/空会话、totals、orphans、capabilities、limits;**无会话行** |
56
+ | `GET /sessions?workspaceId=&tab=&query=&sort=&offset=&limit=&subagents=&blank=&sizes=` | 单工作区一页(≤50 行);branch-aware 排序 + `id` tie-break;`parentStub`/`branchDepth` |
57
+ | `GET /search?query=&…` | 跨工作区标题/路径/id/父标题搜索,每命中组一页 + total |
58
+ | `GET /session?id=` | 详情单行 |
59
+ | `GET /blanks?subagents=` | 空会话三组 id 列表 |
60
+ | `GET /sizes?workspaceId=all\|ungrouped\|<id>&subagents=` | 大小统计(一次 `sessionPersistence.list()` + 60 s TTL,只聚合可见行) |
61
+
62
+ 0.2.0 的读面全部是 GET;`/state` 保留给 0.1.x 客户端与老 Host 回退。参数校验失败/未知 workspace 走既有错误信封(`bad-request` / `workspace-not-found`),大小扫描失败返回 503 `sizes-pending`。
55
63
 
56
64
  所有 id 必须是 host 当前已知的会话 id;workspaceId 必须来自 `workspaceRegistry.list()`。批量上限:`maxBatch`(默认 50)/ `maxMoveBatch`(默认 20);级联删除总上限 500。
57
65
 
@@ -224,4 +232,43 @@ targetDir = dirname(locate({id, cwd: target.path}))
224
232
  - **批量操作**:只使用 `visibleRows` 中的已选行;`全选当前列表` 只选可见集;批量条提示“选择仅作用于当前筛选与展开结果”。
225
233
  - **吸附**:过滤工具栏与批量条包在同一个 `.dsm-sticky`(`position: sticky; top: 0`)中,滚动时整体吸在设置弹窗内容区顶部;批量条不再是独立 sticky。
226
234
 
227
- 0.2.0 会把 `state` 拆成“工作区概览 + 按工作区分页 sessions”,`computeView` 的过滤/分组逻辑保留,但数据来源从全量数组改为服务端页 + 缓存。
235
+ 0.2.0 已把 `state` 拆成「工作区概览 + 按工作区分页 sessions」:过滤/排序/分页/分支嵌套在 Host 完成,客户端只保留组顺序、展开集合、选择集与渲染;老 Host 回退(`/state`)时仍由客户端 `computeView` 做每组 50 条本地窗口。详见第 14 节。
236
+
237
+ ## 14. 0.2.0 读取面与零 IO catalog
238
+
239
+ 目标:1000–10000 条会话时首屏与翻页都不再拉全量。
240
+
241
+ ### 14.1 数据来源(快路径)
242
+
243
+ `lib/catalog.js#fromContext` 从两块宿主内存数据组装全部行:
244
+
245
+ 1. `workspaceRegistry.headers`:`Map<id, header>`,宿主启动时由 `sessionPersistence.list()` 一次性建好,含全部已存储会话的 parentSession/origin/delegationDepth/isSeeded/createdAt/cwd/agentPreset。插件**不额外读盘**。
246
+ 2. `sessionProjectionCache.cachedSnapshot(header)`:零 IO 返回 `title`、`sessionListMetadata{blank,lastPromptAt}`;live 会话用 `sessionProjections.cachedSnapshot(session)`。
247
+
248
+ 在此基础上合并 `ctx.sessions.list()`(live 会话)、`archivedSessionIds` / `pinnedSessionIds`、workspace 账本(`sessionIds` → cwd → ungrouped),计算三态分类与全域 `parentTitle` / `parentPresent` / `forkCount`。行字段与 0.1.x `/state` 兼容。
249
+
250
+ 两个 seam 都是版本敏感点:`headers` 必须是 Map、投影缓存服务可 `ctx.get('sessionProjectionCache')`。任一缺失时 `catalogAvailable()` 为 false,`service.catalogFor()` 走 `createLegacyCatalogCache`(`buildState` + 30 s TTL);返回结构完全相同,只有 `source: "legacy"` 和成本不同。
251
+
252
+ ### 14.2 分页、排序与分支
253
+
254
+ - 过滤:tab / workspaceId(或 ungrouped)/ query / blank / subagents 全部在 Host 完成;`showBlank=false` 时在分页前剔除空会话。
255
+ - 排序:`updated`(`max(createdAt,lastPromptAt)`)/`created`/`title`/`size`,全部追加 `id` 升序 tie-break。
256
+ - branch-aware:先排序,再用 `orderBranchRows` 把分支挂到父行之后(父不在过滤集则保持顶层),最后 `slice(offset, offset+limit)`。`total` 是过滤后的总数,`hasMore` 由此得出。
257
+ - `parentStub`:fork 行的父不在当前页时下发 `{id,title,present}`,客户端据此区分「来自某父」与「父已不存在」;`forkCount` 始终是全域名额。
258
+ - 标题:常规只读投影缓存;本页仍缺标题时,才对本页 ≤50 条做 2 s 预算的 `readTitleSnapshots` 兜底,仍缺失计入 `titlesPending`。
259
+
260
+ ### 14.3 大小
261
+
262
+ - 页内:`readLogFacts` 用 `sessionPersistence.locate()` 推目录 + `currentLogFile`(readdir 选最高代)+ `stat`,只读本页文件;不用 `findSessionDirs`/`stat(id)`(miss 时会全库扫描)。
263
+ - 总量:`GET /sizes` 一次 `sessionPersistence.list()`(并发 3),聚合 **catalog 可见行**(随 `subagents` 开关),进程内 TTL 60 s;归档/恢复/移动/删除/改标题后失效。`sizeScanMax` 超出时返回 `sizes-pending` 降级。
264
+ - 客户端首屏不等待大小:先渲染计数,`/sizes` 后台返回后补「总大小/组大小」,失败显示「—」。
265
+
266
+ ### 14.4 进程内存记账
267
+
268
+ - `deletedIds`:本进程删除后注册表 header 缓存仍可能保留到重启;删除成功后记账并从 catalog 过滤,`/sizes` 对账时清理。
269
+ - `titleOverrides`:冷会话 `title/apply` 后宿主投影缓存下次 fold 前可能仍是旧标题;写成功后记账优先使用,5 分钟 TTL + `/sizes` 对账收敛。
270
+ - 幽灵行:外部删除由页内 stat(缺日志则不显示大小)与 `/sizes` 对账收敛,不追求跨进程实时一致。
271
+
272
+ ### 14.5 客户端
273
+
274
+ `overview` 首屏 → 每个展开工作区一页 → 加载更多按 offset 追加;pages 缓存 + rowCache + scopeKey 失效;并发 3。搜索防抖后走 `/search`;选择只允许已加载且展开的行,scope 变化清空;详情用 `/session` 独立取数,不再因刷新清空;`window.focus` 只静默刷 overview。老 Host(`/overview` 404)一次性回退到 `/state`,保留每组 50 条本地窗口与「加载更多」。
@@ -147,3 +147,22 @@ curl -s https://awesome-dsh-plugin.com/plugins.json | grep -i session-mng
147
147
  - Git:main 已 push(功能提交 `a827495`);tag `v0.1.3`;Release https://github.com/ZilongYang/dsh-session-mng/releases/tag/v0.1.3
148
148
  - marketplace:条目不需要改 YAML,卡片版本随 npm 自动更新;PR [#9672](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/9672) 仍只差仓库年龄 gate。
149
149
  - 遗留:当前安装的 DSH `0.2.1-rc.3` 构建仍写 fork 的 `parentSession`(「独立 fork」变更只在 DSH 源码里),所以历史分支形态会继续出现,0.1.3 的删除/移动策略长期有效。
150
+
151
+ ## 11. 0.1.4 更新发布(2026-10-28)
152
+
153
+ - 交互修复:详情页标题/操作行吸顶;操作结果改为右下角浮动 toast(成功 5 秒自动隐藏);头部按钮移到标题右侧。
154
+ - 空会话:「归档空会话(N)」一键分批归档;「清理空会话(N)」只要有已归档空会话就显示;删除范围预览与删除按 `maxBatch`(50)分批,一次确认可清理任意数量。
155
+ - `npm publish`:`dsh-session-mng@0.1.4`,37 个文件,583.2 kB / unpacked 834.8 kB,shasum `aac611c2e007d45dda370c9b96337a914d300cda`;本次 registry 传播约 6 分钟,`latest` = 0.1.4,`dist.fileCount` = 37。
156
+ - Git:main 已 push(功能提交 `0cf4d55`);tag `v0.1.4`;Release https://github.com/ZilongYang/dsh-session-mng/releases/tag/v0.1.4
157
+ - marketplace:条目不需要改 YAML,卡片版本随 npm 自动更新为 0.1.4。
158
+
159
+ ## 10. 0.2.0(2026-10-29,实现完成,待发布)
160
+
161
+ 0.2.0 是 Host 读面重构(overview + 分页 sessions + catalog),不属于市场条目变更:marketplace 只读 `package.json` 的 `repository`/版本,条目 YAML 不需要改;npm 发布后卡片版本自动更新。
162
+
163
+ - 计划:[PLAN-2026-10-29.md](PLAN-2026-10-29.md);发布清单:[RELEASE-CHECKLIST-0.2.0.md](RELEASE-CHECKLIST-0.2.0.md)。
164
+ - 本地:129 测试全绿;10k 合成基准 overview 9.5 ms / 1338 B、单页 8.8 ms / 25.3 KB;`npm pack --dry-run` 41 文件 / 628.2 kB。
165
+ - 已完成(2026-10-29):npm `dsh-session-mng@0.2.0` 发布(41 文件 / 629.8 kB,`latest`=0.2.0,版本端点 200)、tag `v0.2.0`、GitHub Release https://github.com/ZilongYang/dsh-session-mng/releases/tag/v0.2.0 。
166
+ - 站点 `dsh.zlmix.com/session-mng/` 已更新并部署(站点仓库提交「content: track dsh-session-mng 0.2.0」;自检 0 error / 0 warning;线上 8 条路径全部 200,未知路径 404,index.html 校验一致);线上数据版本 `0.2.0`、DSH tested `0.2.1-rc.3`。
167
+ - awesome-dsh-plugin 的 `plugins.json` 在发布后仍显示 `0.1.4`(它自己的同步/构建缓存),待其刷新后确认卡片版本变为 `0.2.0`;条目 YAML 无需修改。
168
+ - 截图仍为 0.1.x 图片(界面变化不大),需要时可重拍。
@@ -0,0 +1,105 @@
1
+ # dsh-session-mng 接手说明(2026-10-29)
2
+
3
+ > 面向下一个接手的人/会话。先读这一页,再按链接进入详细文档。当前代码在 `main`,工作区应保持干净。
4
+
5
+ ## 1. 当前状态
6
+
7
+ | 项 | 值 |
8
+ |---|---|
9
+ | 版本 | `0.2.0` 已发布(npm latest / tag `v0.2.0` / Release,2026-10-29) |
10
+ | npm | https://www.npmjs.com/package/dsh-session-mng(`latest` = 0.2.0) |
11
+ | GitHub | https://github.com/ZilongYang/dsh-session-mng |
12
+ | 当前 main | B0 `d251b41` → Host B1+B2 `fc2b881` → sizes 口径 `49eadd8` → Client B3+B4 `f19cd6b`;0.2.0 文档提交见其后 |
13
+ | 测试 | `node --test` **129 个全绿**(Host 72 + 客户端 vm 57) |
14
+ | 包内容 | `npm pack --dry-run` 41 个文件 / 628.2 kB(unpacked 978.2 kB) |
15
+ | 本地安装 | 已作为 symlink 安装到 Desktop `desktop` profile;Client 改动刷新页面即可,**Host 改动需重启 DSH** |
16
+ | 实测宿主 | DSH `0.2.1-rc.3`(Desktop app.asar);`0.2.1-rc.2` 的可执行环境同源,已核对 |
17
+
18
+ ## 2. 0.2.0 做了什么
19
+
20
+ 目标:真正支撑 1000–10000 条会话,首屏不再拉全量 `state`。
21
+
22
+ - **Host catalog(`lib/catalog.js`)**:只用宿主已有的两块内存数据组装会话行——`workspaceRegistry.headers`(启动时由 `sessionPersistence.list()` 建好的全量 header Map)与 `sessionProjectionCache`(title / `sessionListMetadata{blank,lastPromptAt}`;live 会话用 `sessionProjections.cachedSnapshot`)。常规路径**不调用** `sessionController.list()` / `sessionPersistence.list()` / 批量 `readTitleSnapshots()`。两个 seam 全部特性检测,缺失时回退 0.1.x 的 `buildState` + 30 s TTL,接口形状不变。
23
+ - **7 条只读路由**(`lib/api.js`):`/overview`、`/sessions`、`/search`、`/session`、`/blanks`、`/sizes`,以及保留的 `/state`。契约见 `PLAN-2026-10-29.md` 3.2 与 `RELEASE-CHECKLIST-0.2.0.md`。
24
+ - **Client 两段式**:首屏 overview → 展开取一页(≤50)→ 加载更多 offset 追加;搜索走宿主;选择只作用于已加载行;详情走 `/session`;老 Host 404 回退 `/state` 并保留每组 50 条本地分页。
25
+ - **性能基准(10k 合成)**:overview 9.5 ms / 1338 B;单页 8.8 ms / 50 行 / 25.3 KB;全量扫描调用 0 次。
26
+
27
+ ## 3. 代码地图(相对 0.1.x 的增量)
28
+
29
+ | 文件 | 职责 / 接手要点 |
30
+ |---|---|
31
+ | `lib/catalog.js` | 零 IO catalog;`fromContext`(快路径)/`fromState`(慢路径);`buildOverview`/`buildPage`/`buildSearchGroups`/`buildBlanks`/`buildDetail`/`decorateRows`;branch-aware `sortRows`/`orderBranchRows`;`readPageTitles`(本页 2 s 预算);`createLegacyCatalogCache` |
32
+ | `lib/api.js` | `GET_ROUTES`:state/overview/sessions/search/session/blanks/sizes;未知路由 404、读路由 POST→405;`GET_TIMEOUT_MS=20s` |
33
+ | `lib/service.js` | 读方法 `overview/sessions/search/session/blanks/sizes`;`sizeState`(一次 list + TTL + epoch 失效)/`deletedIds`/`titleOverrides`/`legacyCatalog`;写操作后失效 |
34
+ | `lib/store-jsonl.js` | `readLogFacts`/`readLogFactsBatch`(locate 推目录 + readdir 选最高代 + stat);`currentLogFile` 带 `generation` |
35
+ | `lib/state.js` | 抽出 `serviceOf`/`isRunningSession`/`capabilitiesOf`/`canOf` 供 catalog 复用;`buildState` 行为不变 |
36
+ | `client.js` | 数据层 + 纯函数(`dataScopeKey`/`composeGroups`/`annotateBranchRows`/`mergePageRows`/…);`__test` 导出供 node:vm 测试;legacy 回退每组 50 条本地窗口 |
37
+ | `test/` | `client.test.js`(57 个,node:vm + React stub + 请求链路 harness);Host 新增 catalog/overview/sessions-page/search-blanks-sizes |
38
+
39
+ ### 不要改坏的行为
40
+
41
+ - `/state` 必须继续可用(0.1.x 客户端与老 Host 回退都依赖它)。
42
+ - 计数口径:`overview.totals.all` 是 subagents 过滤后的可见行数;`blank` 仍计入行、只是客户端隐藏;`/sizes` 与 `overview` 同口径。
43
+ - 分页稳定:所有排序必须有 `id` tie-break;`generatedAt` 变化后客户端重取当前展开组。
44
+ - 选择安全:scope 变化清空选择;批量提交前过滤不在 `rowCache` 的 id。
45
+ - 分支跨页:`parentPresent` 是**全域**是否存在;父未加载≠父已删除,用 `parentStub`/`parentNotLoaded` 区分。
46
+
47
+ ## 4. 下一步任务
48
+
49
+ ### P0 — 0.2.0 实机验收与发布(必须由子龙确认)
50
+
51
+ > 0.2.0 已在隔离实例上完成真机只读验收(真实 DSH 0.2.1-rc.2 + 本仓库 symlink + 真实数据副本;Host/Client 均加载,首屏无 `/state`,分页/搜索/详情/空会话/分支全部通过,详见 `RELEASE-CHECKLIST-0.2.0.md` B 节)。剩下的是**当前 Desktop 应用重启后的最终确认**与发版。
52
+
53
+ 1. **重启 Desktop DSH** 让 Host 改动生效(Client 刷新即可;Host 不行)。当前运行中的旧 Host 对 `/overview` 返回 405。
54
+ 2. 只读 curl(loopback):
55
+ ```sh
56
+ curl -s http://127.0.0.1:58771/session-mng/api/overview | head -c 800
57
+ curl -s 'http://127.0.0.1:58771/session-mng/api/sessions?workspaceId=<id>&limit=50' | head -c 800
58
+ curl -s 'http://127.0.0.1:58771/session-mng/api/sizes?subagents=0'
59
+ ```
60
+ 验收点见 `RELEASE-CHECKLIST-0.2.0.md` B 节;写操作只用一次性测试会话。
61
+ 3. 重拍 `assets/screenshot-*.png`(列表/分页/搜索/详情/删除范围)。
62
+ 4. `npm publish` / tag / Release / 回填 README、ROADMAP、`02-open-source-and-marketplace.md`、PLAN 事后差异(命令见下)。
63
+
64
+ ### P1 — 后续候选(不急,按 ROADMAP 排)
65
+
66
+ - 内容全文搜索(宿主的 SQLite FTS 在本部署默认关闭、且索引不含标题;接入前先确认 composition 与结果模型)。
67
+ - 导出所选会话为 Markdown/JSON;置顶/取消置顶;整枝移动;会话体检/修复;回收站;增量移动。
68
+ - `(sortValue,id)` keyset 游标(替换裸 offset,进一步抗数据变化)。
69
+
70
+ ## 5. 发布 / 开发命令
71
+
72
+ ```sh
73
+ cd /Users/zilong/zWork/ai/zl-plugins/dsh-session-mng
74
+
75
+ npm test
76
+ node --check index.js client.js lib/*.js test/*.js
77
+ npm --cache /tmp/dsh-npm-cache pack --dry-run
78
+
79
+ # npm 发布(0.1.x/0.2.0 均可能出现 202 占位,约 1–6 分钟后 packument 出现新版本;以版本端点为准)
80
+ npm --cache /tmp/dsh-npm-cache publish
81
+ curl -s -o /dev/null -w "%{http_code}\n" https://registry.npmjs.org/dsh-session-mng/0.2.0
82
+
83
+ # GitHub
84
+ git push origin main
85
+ git tag v0.2.0 && git push origin v0.2.0
86
+ gh release create v0.2.0 --repo ZilongYang/dsh-session-mng --title "dsh-session-mng 0.2.0" --notes-file /tmp/notes.md
87
+ ```
88
+
89
+ ## 6. 环境与坑
90
+
91
+ - DSH `0.2.1-rc.3`;Desktop `desktop` profile **由应用独占**,不要用 `dsh plugin --profile desktop …` 管理;用应用内插件管理器或 `plugin_manager`。
92
+ - **两个内部 seam**:`workspaceRegistry.headers`(`Map<id, header>`,启动后新建且未 attach 的会话可能暂缺,靠 live 合并)与 `sessionProjectionCache.cachedSnapshot(header)`(identity 无 origin/parent/字节数)。升级 DSH 先跑测试 + 实机;seam 变形时自动走慢路径,不会失败。
93
+ - `sessionPersistence.stat(id)` 会扫全部项目目录,**不是** O(1);页内大小用 `readLogFacts`(readdir+stat)。
94
+ - 本部署 `sessionQuery.searchSessions` 不可用(dsh-base 写死 `:memory:` + `openAt:'never'`,抛 `SESSION_QUERY_SEARCH_DISABLED`),且 FTS 文本不含标题;所以 0.2.0 的搜索是内存 catalog 的标题/路径/id 搜索。
95
+ - `webRuntime` 在 rc.3 中不存在 → fence 恒回退 loopback-only(现状,不影响)。
96
+ - 测试/截图只用一次性测试会话或 `/tmp` 假数据;不要提交真实标题/路径/会话 id。
97
+ - 不要为了验收起第二个 DSH 实例指向同一个 `~/.dsh`(storage domain / 投影缓存是单进程所有者);需要隔离时用独立 `DSH_HOME`,但那样没有真实投影缓存,只能验证慢路径。
98
+
99
+ ## 7. 关键文档
100
+
101
+ - [0.2.0 批准计划](PLAN-2026-10-29.md)
102
+ - [0.2.0 发布检查清单](RELEASE-CHECKLIST-0.2.0.md)
103
+ - [设计与实现说明](01-design.md)
104
+ - [开源/市场记录](02-open-source-and-marketplace.md)
105
+ - [路线图](../ROADMAP.md) / [变更记录](../CHANGELOG.md)
@@ -0,0 +1,245 @@
1
+ # dsh-session-mng 0.2.0 实施计划(Host 侧 overview + 分页 sessions)
2
+
3
+ > 日期:2026-10-29。状态:**待子龙批准**(批准后本文按原样存档,实施差异另起「事后差异」一节)。
4
+ > 目标版本:`0.2.0`。上游依据:`ROADMAP.md` 0.2.0、`docs/HANDOVER-2026-10-27.md` P1。
5
+ > 前置:`0.1.4` 已发布;0.1.1 的折叠/分页只在客户端压 DOM,`GET /state` 仍然一次组装全部会话。
6
+
7
+ ## 1. 现状与实测(本机,2026-10-29)
8
+
9
+ | 指标 | 当前数据 | 推算 10 000 条 |
10
+ |---|---|---|
11
+ | 会话行 | 107(含子代理 238) | 10 000 |
12
+ | `GET /state?subagents=0` | 69.6 KB / 0.30 s | ≈ 6.5 MB / 数十秒 |
13
+ | `GET /state?subagents=1` | 160.7 KB / 0.69 s | ≈ 6.7 MB / 数十秒 |
14
+ | 宿主全量扫描 | `sessionController.list()` + `sessionPersistence.list()` 各一遍 O(n) | 同上,随会话数线性增长 |
15
+
16
+ 慢在三处:
17
+
18
+ 1. **Host 全量组装**:`lib/state.js` 调 `sessionController.list()`(内部 `sessionQuery.listSessions()` → `sessionPersistence.list()`:readdir + 逐会话读首帧 + stat,并发仅 3)再补一遍 `persistence.list()` 的 size/format,两遍 O(n) 持久化 IO。更慢的是「缺标题」兜底:`readTitleSnapshots(ids)` 对冷会话会**读取整份日志并 replay**,是批量补标题时最贵的一步,新接口必须避免对它做全量调用。
19
+ 2. **JSON 体积**:每行约 650 B,10 000 行 ≈ 6.5 MB。
20
+ 3. **Client 单页全量**:`client.js` 拿到全部行后才做 `computeView` 折叠/分页;行多时首屏渲染与搜索都变慢。
21
+
22
+ 结论:只做客户端分页不够,必须把「首屏」变成不含会话行的 overview,把「会话行」变成按工作区分页;且新接口的常规路径不能再批量调用 `readTitleSnapshots` / `sessionController.list`。
23
+
24
+ ## 2. 关键宿主能力(决定实现方式)
25
+
26
+ 已在本机核对 DSH **0.2.1-rc.2**(`~/Library/pnpm/global/.../@deepseek-ai/*` 解包源码)与 **0.2.1-rc.3**(Desktop app.asar 内同一批 per-package 源码),两者一致:
27
+
28
+ | 能力 | 事实 | 用途 |
29
+ |---|---|---|
30
+ | `workspaceRegistry.list()` / `entity.sessionIds` | 同步、纯内存,不读持久化;`sessionIds` 顺序=创建时间倒序 + 新会话前插;**含已归档**(归档刻意保留槽位);entity 无 archived 字段、无每工作区归档列表 | overview 的工作区顺序、会话/归档计数,零会话 IO |
31
+ | `workspaceRegistry.archivedSessionIds` / `pinnedSessionIds` | registry 全局**有序数组**(不是 Set),读零 I/O | 归档/置顶状态;每工作区归档数=与 `sessionIds` 求交 |
32
+ | `workspaceRegistry.headers` | `Map<id, header>`,启动时由 `sessionPersistence.list()` 一次性建好,**含全部已存储 header**(parentSession/origin/delegationDepth/isSeeded/createdAt/cwd/agentPreset 齐全) | 内存 catalog 的主来源;分类、父子、全局 `forkCount` 都靠它,零额外 IO |
33
+ | `sessionProjectionCache.cachedSnapshot(header)` | 零 IO 返回 `title`、`sessionListMetadata{blank,lastPromptAt}`;live 会话用 `sessionProjections.cachedSnapshot(session)`。identity 只有 formatVersion/createdAt/cwd/isSeeded/inheritedEventCount,**没有 origin/parent/字节数** | 标题、更新时间(`max(createdAt,lastPromptAt)`,与宿主列表公式一致)、空会话判定 |
34
+ | `sessionPersistence.list()` / `stat(id)` | `list` 是 O(n) 扫盘(3N 次 fs、并发 3);`stat(id)` 会扫全部项目目录,**不是 O(1)**;`locate(meta)` 按当前格式版本拼文件名,遇到旧代文件会 ENOENT | 只用于「大小统计」与慢路径兜底;页内大小用 `store-jsonl` 的 readdir + 选最高代 |
35
+ | `sessionController.list(_request, signal)` | request 被完全忽略:**无 filter/limit/offset/query/sort**;内部 `listSessions()` + 每冷行零 IO 摘要;无分页/增量接口 | 慢路径兜底;新接口常规路径**不得**调用 |
36
+ | `sessionQuery.readTitleSnapshots(ids)` | 对冷会话**逐个读取整份日志并 replay**(并发 4) | 现状 `/state` 慢的另一主因;新接口只对**本页缺失标题**做有超时上限的兜底 |
37
+ | `sessionQuery.searchSessions` | 只有 SQLite FTS 后端实现;本部署 `dsh-base` 写死 `:memory:` + `openAt:'never'` → 必抛 `SESSION_QUERY_SEARCH_DISABLED`;且 FTS 文本**不含标题** | 0.2.0 **不接**内容搜索;`capabilities.search` 如实上报,标题搜索走内存 catalog |
38
+ | `webRuntime` | rc.3 asar 中不存在(0 命中) | fence 恒回退 loopback-only(现状,不影响本计划) |
39
+
40
+ 两个内部 seam 的处理方式与现有 `move` 一致:**特性检测 + 公共 API 慢路径兜底**,缺失时功能降级、不报错、不写坏数据。
41
+
42
+ - `headers` 是 `Map`(`typeof headers.get === 'function'`、`size` 可读);
43
+ - 投影缓存通过 `ctx.get('sessionProjectionCache')` 取,`cachedSnapshot` 可调用。
44
+
45
+ 已知覆盖缺口(都要在实现里处理):
46
+
47
+ - 投影缓存**不保证覆盖**(部署可不挂、刚创建/未到检查点的会话可能没有):标题按页兜底,`blank` 缺失时按 `false` 处理并标 `unknown`。
48
+ - 启动后新建**且未 attach** 的会话可能暂时不在 `sessionIds`:catalog 用 `ctx.sessions.list()` 补齐,按 `cwd` 归组兜底,下一个请求自然收敛。
49
+ - `headers` 与磁盘的一致性:本进程删除后需过滤(见 3.5);外部删除依靠页内 stat 与 `/sizes` 对账收敛。
50
+
51
+ ## 3. Host 设计
52
+
53
+ ### 3.1 内存 catalog(新文件 `lib/catalog.js`)
54
+
55
+ 一次请求内组装,全部来自内存,**零会话日志 IO**:
56
+
57
+ ```
58
+ headers(registry) ∪ live(ctx.sessions.list())
59
+ ├─ 标题 / lastPromptAt / blank ← 投影缓存(live 用 sessionProjections)
60
+ ├─ archived / pinned ← registry.archivedSessionIds / pinnedSessionIds
61
+ ├─ live / running ← ctx.sessions.get / ctx.agents.get
62
+ ├─ kind ← classifySession(沿用 lib/state.js)
63
+ └─ workspaceId ← ledger sessionIds;否则 header.cwd 匹配;否则 ungrouped
64
+ 再全域计算 parentTitle / parentPresent / forkCount(口径与 0.1.3 完全一致)
65
+ ```
66
+
67
+ 行字段与 0.1.x `/state` 行保持兼容(`id/title/cwd/workspaceId/archived/pinned/live/running/blank/kind/isFork/isSubagent/parentSessionId/parentTitle/parentPresent/forkCount/agentPreset/createdAt/updatedAt/can`)。`formatStatus` 不常驻 catalog:页内由选中代文件名推断,命中 `/sizes` 缓存时用缓存值,详情由单行 stat 补齐。
68
+
69
+ `sizeBytes` 不在内存 catalog 里:只在「返回页」和「/session 详情」时按 id 读取当前日志文件大小(≤50 个文件 stat)。
70
+
71
+ ### 3.2 新接口(全部 GET,与既有前缀同一 fence/错误信封)
72
+
73
+ | 路由 | 说明 |
74
+ |---|---|
75
+ | `GET /overview?subagents=0\|1` | 不含任何会话行 |
76
+ | `GET /sessions?workspaceId=<id>\|ungrouped&tab=all\|active\|archived&query=&sort=updated\|created\|title\|size&offset=0&limit=50&subagents=0\|1&blank=0\|1&sizes=0\|1` | 单工作区一页;`limit` 上限 50 |
77
+ | `GET /search?query=&tab=&sort=&subagents=&blank=&limitPerGroup=50` | 跨工作区搜索:返回每个命中工作区的 `{total, rows[≤50]}`,供客户端自动展开 |
78
+ | `GET /session?id=` | 详情单行(含 size/formatStatus/can/parent/forkCount) |
79
+ | `GET /blanks?subagents=0\|1` | 空会话 id 列表:`{unarchivedIds, archivedIds, deletableIds}` |
80
+ | `GET /sizes?workspaceId=<id>\|all` | 大小统计(缓存;见 3.4) |
81
+
82
+ **`/overview` 返回**:
83
+
84
+ ```json
85
+ {
86
+ "generatedAt": 0,
87
+ "source": "catalog" ,
88
+ "workspaces": [{ "id": "", "title": "", "path": "", "status": "ok",
89
+ "count": 0, "activeCount": 0, "archivedCount": 0,
90
+ "blankCount": 0, "totalBytes": null }],
91
+ "ungrouped": { "count": 0, "activeCount": 0, "archivedCount": 0, "blankCount": 0 },
92
+ "totals": { "all": 0, "active": 0, "archived": 0, "subagents": 0,
93
+ "hiddenSubagents": 0, "forks": 0, "blank": 0, "bytes": null },
94
+ "orphans": [],
95
+ "capabilities": { "title": true, "move": true, "delete": true, "search": false, "backend": "jsonl" },
96
+ "limits": {}, "backend": "jsonl"
97
+ }
98
+ ```
99
+
100
+ 计数口径与 `lib/state.js` 完全一致(`totals.all` 为可见行数;隐藏子代理计入 `hiddenSubagents`;`blank` 是隐藏空会话的计数),实现时用同一份 fake ctx 断言 `overview` 与 `buildState` 相等。`capabilities.search` 表示宿主内容搜索是否可用(本部署为 `false`,UI 只做标题搜索,不假装有全文搜索)。
101
+
102
+ **`/sessions` 返回**:
103
+
104
+ ```json
105
+ { "generatedAt": 0, "workspaceId": "", "total": 0, "offset": 0, "limit": 50,
106
+ "hasMore": false, "titlesPending": 0,
107
+ "rows": [ { "...": "同 /state 行", "sizeBytes": 0,
108
+ "parentStub": { "id": "", "title": "", "present": true } } ] }
109
+ ```
110
+
111
+ - Host 先在**整个过滤集**上做 branch-aware 排序(父行的排序键优先、同父内按 `sort`、最后 `id` tie-break),再 `slice(offset, offset+limit)`;`total` 是过滤后的总数,`hasMore` 直接由此得出。
112
+ - fork 行的 `parentTitle`/`parentPresent` 一律按**全域**计算;父不在本页时附 `parentStub`,客户端据此显示「↳ 来自:X」而不是「来源父会话已不存在」。
113
+ - `forkCount` 一律全域计数(不随搜索/分页变化),保持 0.1.3 的「N 分支」与详情移动提示。
114
+ - `showBlank` 关闭时在**分页前**剔除 blank 行,`total/hasMore` 同步是剔除后的数。
115
+ - 标题常规路径只用投影缓存;缓存缺失的本页行才做 `readTitleSnapshots` 兜底(单个请求总预算 2 s、只对本页 ≤50 条),仍无结果则标题为空并计入 `titlesPending`(客户端显示占位,不阻塞)。
116
+
117
+ **`/session` 返回**:`{ "row": {}, "capabilities": {}, "limits": {} }`。
118
+
119
+ ### 3.3 排序与分页稳定性
120
+
121
+ - 排序键:`updated` = `max(header.createdAt, sessionListMetadata.lastPromptAt)`(与宿主 `sessionController.list` 的公式一致);`created`;`title`;`size`(见 3.4)。
122
+ - 所有排序都追加 **`id` 升序 tie-break**,避免同值行在页间重复/漏行。
123
+ - 0.2.0 先用 **offset 分页**(实现简单、支持「回到第 N 页」与整组刷新);响应带 `generatedAt`,写操作后客户端丢弃旧页并重取当前展开组,不做客户端算术调和。`(sortValue,id)` keyset 游标列为后续优化(宿主已给稳定 tie-break,切换成本低)。
124
+ - `sort=size` 依赖全量大小:若大小缓存未就绪,先做一次该 scope 的扫描(上限 `sizeScanMax`,默认 20 000 会话 / 20 s 超时),失败返回 `sizes-pending` 让客户端降级提示,**不**静默按 0 排序。
125
+
126
+ ### 3.4 大小统计(唯一的 O(n) IO,且不在关键路径)
127
+
128
+ - 页内行:`store-jsonl` 的 `findSessionDirs` + `currentLogFile`(readdir 后选**最高代**文件,避免 `locate` 按当前格式拼名遇到的旧代 ENOENT)+ `stat`,只读本页 ≤50 个文件;`formatStatus` 由选中的代文件名推断(最高代即 `current`)。
129
+ - 工作区/全局总量:`GET /sizes` 调一次 `sessionPersistence.list()`(同时可对齐幽灵行、拿准确的 `formatStatus` 与 `sizeBytes`),按工作区聚合,进程内缓存 `sizesTtlMs`(默认 60 s);归档/恢复/移动/删除/标题写入后失效。该结果同时作为页内 size 的优先来源,命中缓存时页请求无需再 stat。
130
+ - 客户端首屏不等待大小:先用 overview 渲染计数,`/sizes` 在后台请求,回来后补「总大小 / 组大小」;失败或超时显示「—」。
131
+
132
+ ### 3.5 一致性处理
133
+
134
+ - **删除后的幽灵行**:`remove()` 成功后进程内记 `deletedIds` 并从 `catalog` 过滤;尽力 `registry.headers.delete(id)`(已有 headers seam 先例,失败不影响结果);`/sizes` 的 `persistence.list()` 结果用作对账,把已不存在的 id 从 catalog 候选集剔除。
135
+ - **冷会话标题刚写入**:`title/apply` 对 cold 会话不经过宿主投影缓存,可能在下次 fold 前显示旧标题;写入成功后记进程内 `titleOverrides: Map<id, {title, at}>`,catalog 优先使用,`/sizes` 对账或进程重启后自然消除。
136
+ - **外部变更 / 新会话**:catalog 每次请求都合并 `ctx.sessions.list()`;未 attach 的新会话按 `cwd` 归组,下一个请求收敛。外部进程的删除/改名由页内 stat 与 `/sizes` 对账收敛,不追求跨进程实时一致(与 0.1.x 的「刷新即准」一致)。
137
+ - **慢路径**:`headers` 或投影缓存缺失时,`catalog` 回退为「`buildState()` 组装 + 30 s TTL 缓存」,接口形状不变(`source: "legacy"`),客户端无感。这条兜底保证插件在未知宿主版本上仍然可用,只是回到 0.1.x 的性能。
138
+
139
+ ### 3.6 兼容
140
+
141
+ - `GET /state` **原样保留**给 0.1.x 客户端;Host 同时提供新旧接口。
142
+ - 新路由参数/边界校验失败返回既有错误信封(`bad-request` 等),未知路由 404。
143
+ - 版本升到 `0.2.0`;`dsh.compatibility` 维持 `>=0.2.0-rc.1 <0.3.0-0`。
144
+
145
+ ## 4. Client 改造(`client.js`)
146
+
147
+ 1. **数据层**:`overview` 首屏 + 每工作区 `pages` 缓存(`{rows,total,offset,hasMore,phase}`)+ `rowCache`;`scopeKey = tab|workspaceFilter|query|sort|includeSubagents|showBlank` 变化时清 `pages`/选择,`overview` 单独按 `generation` 失效;每组一个 `AbortController` + 请求代次,过期响应丢弃;展开/`expandAll` 并发上限 3。
148
+ 2. **首屏换源**:tabs 计数、`totalLine`、`hiddenSubagents`/空会话计数、orphan 横幅改用 `overview`;空会话两个按钮改用 `/blanks` 的 id 列表;`state.limits`/`state.workspaces` 改 `overview.limits`/`overview.workspaces`;`defaultExpanded` 仍用 `totals.all <= 100`。
149
+ 3. **展开 / 加载更多**:展开时取 `offset=0&limit=50`;`loadMore` 取下一页并 append(按 id 去重);`Group` 用 `total - rows.length` 显示剩余、组头大小用 `/sizes`,不再用已加载行 Σ。
150
+ 4. **搜索**:`queryInput` 与 `query` 分离(防抖 250–300 ms);`query` 走 `/search`,命中组自动展开(保持 `locked`);`haystack` 对齐 0.1.3(title + cwd + id + parentTitle)。
151
+ 5. **选择**:只允许选**已加载且已展开**的行;`scopeKey` 变化清空选择;`全选` 只作用于已加载可见集并提示「另有 N 条未加载」;批量提交前过滤掉不在 `rowCache` 的 id(数据安全)。
152
+ 6. **分支跨页**:父在已加载行内 → 缩进挂父;父存在但未加载(`parentStub.present`)→ 顶层 + `↳` + 「来自:父标题」;`parentPresent === false` 才显示「来源父会话已不存在」;新增中性兜底文案「来源父会话未加载」。
153
+ 7. **详情**:`openDetail(id)` 先查 `rowCache` 立即渲染,同时 `GET /session` 补齐/刷新;**删除** `client.js:1492-1495` 的「不在 `state.sessions` 就关闭详情」;`DetailView` 入参改 `{overview, limits, row}`;返回列表时保留页缓存与滚动位置。
154
+ 8. **刷新**:新增 `refresh()`:重取 overview + 对当前展开组按「已加载条数」重取 offset=0 页,保留选择与滚动;写操作后按影响范围失效(归档/恢复/移动失效相关组,删除额外清 `rowCache`);`window.focus` 只静默刷 overview 并标记页面 `stale`,不再全量重取。
155
+ 9. **i18n**:中英各补 `groupLoading`、`groupLoadFailed`、`groupRetry`、`loadedOf`、`partialSelection`、`parentNotLoaded`、`overviewLoadFailed`、`sizesPending`。
156
+ 10. **回退开关**:`GET /overview` 404 → 一次性探测失败,回退 `GET /state` 并沿用 0.1.4 的旧逻辑(保证老 Host + 新 Client 的组合可用)。
157
+
158
+ ## 5. 实施批次(每批单独提交、可独立回滚)
159
+
160
+ | 批次 | 内容 | 验收 |
161
+ |---|---|---|
162
+ | B0 | 抽 `orderBranchRows`/筛选排序分组的纯函数;排序补 `id` tie-break;`viewKey`/`setLimits` 依赖补 `sort`;`__test` 导出 + `node:vm` 客户端纯函数测试骨架 | 无行为变化;29 测试 + 新 client 测试全绿 |
163
+ | B1 | `lib/catalog.js` + `GET /overview` + `GET /sessions`(含 parentStub、页内 size、慢路径兜底)+ 单测 | 客户端不改;curl 断言首屏无 rows、单页 ≤50、不调用 `persistence.list` |
164
+ | B2 | `GET /search`、`/session`、`/blanks`、`/sizes` + 单测 | curl 全部通过;口径与 `buildState` 对齐 |
165
+ | B3 | Client 两段式数据层(overview→页→加载更多)、详情 `rowCache`、计数/空会话/orphan 换源、分支跨页、`/state` 回退 | 当前真实数据(107/238 条)实机;无计数/按钮回退 |
166
+ | B4 | 搜索防抖 + 选择 loaded 语义 + `refresh()`/focus + 并发上限 + i18n | 交互回归清单逐项通过 |
167
+ | B5 | 10 000 条合成基准 + 性能验收 + 真实数据只读验收 + 重启实机 | 见第 6 节数字 |
168
+ | B6 | 文档与发版(README/CHANGELOG/ROADMAP/01-design/HANDOVER/`RELEASE-CHECKLIST-0.2.0.md`/截图) | `npm pack` + 发版清单 |
169
+
170
+ ## 6. 测试与验收
171
+
172
+ **单元测试(新增)**
173
+
174
+ - `test/catalog.test.js`:headers ∪ live、投影缓存标题/blank/lastPromptAt、三态分类、parentTitle/parentPresent/forkCount、归档/置顶、未分组、orphans、deletedIds/titleOverrides、慢路径回退。
175
+ - `test/overview.test.js`:与 `buildState` 同 fake ctx 下计数逐字段相等(含 subagents/blank 开关)。
176
+ - `test/sessions-page.test.js`:tab/workspace/query/sort/offset/limit/hasMore、分页无重无漏(id tie-break)、branch-aware 顺序、parentStub、`showBlank` 分页前过滤、size 按需。
177
+ - `test/search-blanks-sizes.test.js`:跨工作区分组、`/blanks` 三组 id、`/sizes` 聚合与 TTL 失效。
178
+ - `test/api.test.js` 扩展:新路由的 405/404/参数校验/fence。
179
+ - `test/client.test.js`(B0 起):`node:vm` + stub React 加载 `client.js`,测 `computeView`/`orderBranchRows`/`pruneSelection`/`mergeRows`/`scopeKey`。
180
+
181
+ **性能验收(10 000 条合成数据,fake ctx)**
182
+
183
+ - `/overview`:**不**调用 `sessionPersistence.list` / `sessionController.list`;响应无 rows;构建 < 50 ms;payload < 2 KB(无 sizes)。
184
+ - `/sessions`:单页 ≤ 50 行;构建 < 100 ms(不含 size);payload ≤ 60 KB;`hasMore` 正确。
185
+ - 现有 29 个测试全绿;`node --check`;`npm pack --dry-run`。
186
+
187
+ **实机验收(只读为主,写操作只用一次性测试会话)**
188
+
189
+ - `/overview` 不含会话行;展开工作区只发一页请求;不出现全量 `/state`。
190
+ - 分支跨页文案正确(父未加载 ≠ 父已删除);搜索父标题仍命中分支。
191
+ - 空会话「归档空会话/清理空会话」按钮仍在且可用;orphan 清理可用。
192
+ - 详情进入/刷新/返回列表不闪退;归档/恢复/移动/删除后列表与计数一致。
193
+ - 1000 / 10 000 条合成数据用 `/tmp` 假数据目录验证;不提交真实标题/路径。
194
+
195
+ ## 7. 不做(留给后续)
196
+
197
+ - 全文内容搜索(宿主 FTS 依赖 + 需要可与标题搜索合并的结果模型)。
198
+ - 虚拟滚动(分页后不再是瓶颈)。
199
+ - 整枝移动、导出、置顶、回收站、非 JSONL 后端、跨主机迁移。
200
+ - 自动标题 provider / 自动归档规则。
201
+
202
+ ## 8. 风险与对策
203
+
204
+ | 风险 | 对策 |
205
+ |---|---|
206
+ | 内部 seam(`registry.headers`、`sessionProjectionCache`)在宿主升级后变形 | 每次访问都特性检测;缺失走 `buildState` 慢路径;版本敏感点写进 README |
207
+ | 投影缓存覆盖不足(未挂载 / 新会话未落检查点) | 标题只对本页缺失行做 2 s 预算的 `readTitleSnapshots` 兜底;`blank` 缺失按未知处理;overview 计数允许「短时偏低」并在刷新后收敛 |
208
+ | 幽灵行(外部删除 / 本进程删除后 header 缓存未清) | 进程内 `deletedIds` + 尽力 `headers.delete`;`/sizes` 对账;页内 stat 失败即剔除 |
209
+ | 冷会话标题写入后缓存滞后 | 进程内 `titleOverrides` 优先;重启/对账后自然消除 |
210
+ | 分支跨页把「未加载」谎报成「已删除」 | `parentPresent` 全域口径 + `parentStub` + 中性文案兜底 |
211
+ | 选择集误伤未加载行(数据安全) | loaded 语义 + `scopeKey` 清空 + 提交前过滤 |
212
+ | `focus`/`expandAll` 请求风暴 | overview 单独刷新 + 页面 staleness + 并发上限 3 |
213
+ | `sort=size` 触发 O(n) 扫描 | 只按需、带上限与超时,失败明确提示,不静默错排 |
214
+ | offset 分页在数据变化后漂移 | `id` tie-break + `generatedAt` 失效 + 刷新当前展开组;后续可切 keyset |
215
+ | 计数口径漂移(tab/blank/subagent) | overview 与 buildState 同 fake ctx 断言;分页前过滤 |
216
+ | 宿主无字节索引,`/sizes` 在 10k 时可能 5–20 s | 异步、可取消、TTL 缓存;首屏与分页完全不依赖它 |
217
+
218
+ ## 9. 待子龙确认(推荐项已标注)
219
+
220
+ 1. **范围**:0.2.0 一次做完 Host + Client(推荐);或先发 Host-only 0.2.0、客户端 0.2.0 之后再发 0.2.1。
221
+ 2. **零 IO catalog**:接受把 `workspaceRegistry.headers` 与 `sessionProjectionCache` 作为特性检测的快速路径(推荐,已有 move seam 先例;否则只能维持 O(n) 全量扫描,达不到首屏目标)。
222
+ 3. **大小策略**:页内行按需 stat、总量异步 `/sizes`(推荐);或 0.2.0 暂时去掉大小显示。
223
+ 4. **搜索范围**:0.2.0 用内存 catalog 做标题/路径/id 搜索(推荐),全文内容搜索留后续;或 0.2.0 就接入宿主 FTS(依赖 sqlite 后端,结果模型不同)。
224
+ 5. **客户端测试**:接受 B0 的 `node:vm` 纯函数测试(推荐,零构建依赖)。
225
+
226
+ > 批准后:本文原样存档;实施差异按惯例在文末追加「事后差异(补记)」,并同步 `docs/RELEASE-CHECKLIST-0.2.0.md`、README 文档索引与 ROADMAP。
227
+
228
+ ---
229
+
230
+ ## 事后差异(补记,2026-10-29)
231
+
232
+ > 以下是批准计划与最终实现的差异;批准时的正文保持原样。实施提交:B0 `d251b41`、Host B1+B2 `fc2b881`、sizes 口径 `49eadd8`、Client B3+B4 `f19cd6b`。
233
+
234
+ 1. **`/sessions` 必须显式给 `workspaceId`**(工作区 id 或 `ungrouped`),不支持 `all`;跨工作区查询走 `/search`。客户端「展开全部」按组各发一页,并发上限 3。
235
+ 2. **`/sizes` 的口径在集成阶段修正**:初版统计 `persistence.list()` 的全部磁盘会话(含隐藏子代理与幽灵行),会让「总大小」比 0.1.4 大;最终改为只聚合 catalog **可见行**,并接受 `subagents=1|0`(与 overview/sessions 同一开关)。`total.bytes` 与同开关下 `buildState.totals.bytes` 相等,`count` 与 `overview.totals.all` 相等。
236
+ 3. **页内 size 的取法**:计划写 `findSessionDirs` + `currentLogFile`;实现改为 `sessionPersistence.locate()` 推目录 + `readdir` 选最高代 + `stat`(`readLogFacts`),避免 `findSessionDirs` 在 miss 时全库扫描。页内 stat 失败的行**不剔除**(否则 total/offset 漂移),由 `/sizes` 对账与下一次请求收敛。
237
+ 4. **标题兜底**:仅对「本页且投影缓存无标题」的 ≤50 行调用 `readTitleSnapshots`,总预算 2 s;仍缺失计入 `titlesPending`,客户端显示占位,不阻塞。批量调用(0.1.x 的 `readTitleSnapshots` 全量兜底)不再使用。
238
+ 5. **`capabilities.search` 恒为 `false`**:本部署宿主 FTS 默认关闭(`dsh-base` 写死 `:memory:` + `openAt:'never'`)且 FTS 文本不含标题;0.2.0 的搜索是 catalog 内存匹配(title + cwd + id + parentTitle),不接入内容全文搜索。
239
+ 6. **慢路径落位**:`headers` 或投影缓存缺失时回退 `buildState` + 30 s TTL,实现在 `lib/catalog.js#createLegacyCatalogCache`,`source: "legacy"`;接口形状不变(行内 size/formatStatus 由 buildState 直接提供)。
240
+ 7. **客户端回退补回分页**:`/overview` 404 回退 `/state` 时,保留 0.1.x「每组 50 条 + 加载更多」的本地窗口(`legacyLimits`),不一次性渲染整组;选择/全选语义仍是「展开组全部行」。
241
+ 8. **详情页独立取数**:新增 `GET /session`,详情先读客户端行缓存立即渲染,再补齐;删除原来的「不在 `state.sessions` 就关闭详情」逻辑,只有 Host 明确 `not-found` 才清空。刷新改为「overview + 已在展开组按已加载深度重取」,`window.focus` 只静默刷 overview。
242
+ 9. **分页游标**:0.2.0 使用裸 offset + `generatedAt` 失效 + 当前展开组重取;`(sortValue,id)` keyset 游标列为后续优化(排序已带 `id` tie-break,切换成本低)。
243
+ 10. **配置项**:新增 `pageLimitMax`(默认 50,上限 200)、`sizesTtlMs`(默认 60000)、`sizeScanMax`(默认 20000)。
244
+ 11. **验证结果**:`node --test` **129 个全绿**(Host 72 + 客户端 vm 57);10k 合成基准 `/overview` 9.5 ms / 1338 B、`/sessions` 单页 8.8 ms / 50 行 / 25.3 KB,`persistence.list` / `sessionController.list` / 批量 `readTitleSnapshots` 调用数 0;`npm pack --dry-run` 41 文件 / 628.2 kB。
245
+ 12. **尚未完成**:Desktop 应用重启后的最终确认(运行中的旧 Host 对 `/overview` 返回 405)与发版(npm/tag/Release,需子龙确认);截图待重拍。0.2.0 已在隔离实例(真实 DSH 0.2.1-rc.2 + 本仓库 symlink + 真实数据副本,独立 `DSH_HOME`)完成真机只读验收:Host/Client 均加载,首屏只发 `overview/blanks/sizes`,分页/搜索/详情/空会话/分支跨页全部通过;详见 `RELEASE-CHECKLIST-0.2.0.md` B 节。
@@ -0,0 +1,72 @@
1
+ # 整枝移动(父会话 + 子代理后代)实施计划
2
+
3
+ > 批准时间:2026-11-02(子龙在对话里选择「A」)
4
+ > 状态:已实施(见文末事后补记)
5
+
6
+ ## 背景
7
+
8
+ 实机反馈:`Tauri 封装 pi-web 项目`(`session-0aaf8279-…`,`zilong` 工作区)移动到 `pi-web-desktop`
9
+ 时一直返回「成功 0,失败 1」,重启 DSH 两次无效。
10
+
11
+ 排查结论:
12
+
13
+ - 会话 header 正常(`cwd` 存在、非分支、非子代理、未归档);
14
+ - 目标工作区已注册且目录存在;
15
+ - 真实原因:它有 **5 个 `origin: subagent` 子会话**(`parentSession` 指向它),
16
+ 当时的 `moveOne` 对 `has-descendants` 直接拒绝。
17
+
18
+ 「有子代理后代的会话不能单独移动」在 0.1.x 是为了避免父会话搬走、子代理会话留在旧项目目录
19
+ 而立的规则。逻辑上子代理会话只服务于根会话,应当跟随根会话一起迁移。
20
+
21
+ ## 方案(批准时的内容)
22
+
23
+ 1. **整枝语义**
24
+ - 根会话的子代理后代(递归,`partitionDescendants` 已按 `kind` 拆分)随根会话一起迁移。
25
+ - 普通分支(fork)是独立用户会话,**不跟随**,留在原工作区(维持 0.1.3 的结论)。
26
+ - 子代理会话仍然不能单独移动(`subagent-session`),只能通过其根会话整枝移动。
27
+
28
+ 2. **先整体校验,再动字节**
29
+ - 逐成员检查:有存储、header 有 `cwd`、非 live、非 running、能定位源目录、目标目录不冲突。
30
+ - 任一成员不满足 → 整个请求在**没有移动任何字节**前失败,并在 `details.sessionId`
31
+ 指出是哪一个成员(例如某个子代理会话已打开)。
32
+
33
+ 3. **逐个迁移 + 逆序回滚**
34
+ - 每个成员复用现有 `stageMove`(移动→复制→改写 header `cwd`→回读校验)与
35
+ `reconcileMove`(header 缓存、`rememberSessionPath`、`attachSession`/`detachSession`)。
36
+ - 根会话先移,子代理按 trace 顺序跟随。
37
+ - 任一成员失败:已完成的成员按逆序 `rollbackMoveReconcile` + `rollbackMove`;
38
+ 回滚自身失败时返回 `manual-recovery` 并列出失败清单。
39
+
40
+ 4. **结果与提示**
41
+ - 返回值新增 `family`(本次迁移的会话数)与 `sessions`(id 列表);`bytes` 为各成员之和。
42
+ - 客户端成功提示追加「连同 N 个子代理会话一起移动」。
43
+
44
+ 5. **顺带修复的 UX 坑**
45
+ - 批量接口可能返回 200 但每条都失败(本次就是),此前 toast 只说「成功 0,失败 1」。
46
+ 现在把每个失败条目的 `code` 与 message 列进 toast 的 details。
47
+
48
+ ## 测试
49
+
50
+ - `a root moves together with its subagent descendants`:根 + 子代理都到新目录、header `cwd`
51
+ 与 `parentSession` 正确、普通分支留下、两个工作区账本正确。
52
+ - `a live subagent refuses the whole family before any byte moves`:子代理 live 时整个请求失败,
53
+ `details.sessionId` 指向该子代理,根会话一个字节都没动。
54
+
55
+ ## 风险
56
+
57
+ - 整枝移动会移动比用户勾选更多的会话(用户勾 1 条,实际搬 1+N 条);已通过提示与文档说明。
58
+ - 子代理会话的状态/归档标记保持不变,与父会话一致性由既有规则保证(整枝只改 `cwd`)。
59
+ - 大日志整枝复制耗时随成员数增加;回滚路径已在单成员上验证过,整枝回滚为逆序复用。
60
+
61
+ ---
62
+
63
+ ## 事后补记(实施后回填,非批准时内容)
64
+
65
+ - 实施位置:`lib/service.js` 新增 `planMember` / `relocateMember` / `undoMember`,`moveOne` 改为
66
+ 「校验整族 → 逐个迁移 → 失败逆序回滚」;`client.js` 的 `run()` 增加批量失败 details、
67
+ `resultText()` 增加 `movedWithFamily`。
68
+ - 与计划差异:无功能差异;计划中的「回滚自身失败时列出清单」实现为 `manual-recovery` 的
69
+ `details.rollback` 数组。
70
+ - 测试:服务端 2 条新用例替换原来的 `subagent descendants still block moving their source`;
71
+ 全套 131 个测试通过。
72
+ - 未做(留到 0.2.1 发版):实机验收整枝移动、README/CHANGELOG 发版记录、版本号提升。
@@ -25,9 +25,10 @@
25
25
  - 80 条已归档空会话:点「清理空会话(80)」→ 预览分 2 批(50+30)请求并合并显示「根会话: 80」;勾选确认后删除也分 2 批,toast 汇总「成功 80,失败 0」。
26
26
  - [ ] 子龙实机:滚动详情页时标题行保持可见;操作后提示在右下角且自动消失;空会话按钮行为符合预期。
27
27
 
28
- ## C. 发布(未开始)
28
+ ## C. 发布(已完成)
29
29
 
30
- - [ ] `npm --cache /tmp/dsh-npm-cache --logs-dir /tmp/dsh-npm-logs pack --dry-run`。
31
- - [ ] `npm publish` 0.1.4;确认 `latest` = 0.1.4。
32
- - [ ] Git commit / push / tag `v0.1.4` / GitHub Release。
33
- - [ ] 更新 README 当前状态、ROADMAP、`docs/02-open-source-and-marketplace.md`。
30
+ - [x] `npm --cache /tmp/dsh-npm-cache --logs-dir /tmp/dsh-npm-logs pack --dry-run`:37 个文件 / 583.2 kB(unpacked 834.8 kB),shasum `aac611c2e007d45dda370c9b96337a914d300cda`。
31
+ - [x] `npm publish` 0.1.4 成功(registry 返回 202 + `+ dsh-session-mng@0.1.4`);本次传播较慢,约 6 分钟后 `0.1.4` 版本端点 200、`latest` = 0.1.4,`dist.fileCount` = 37。
32
+ - [x] Git:功能提交 `0cf4d55`,main 已 push;tag `v0.1.4`;GitHub Release https://github.com/ZilongYang/dsh-session-mng/releases/tag/v0.1.4
33
+ - [x] 更新 README 当前状态、ROADMAP、`docs/02-open-source-and-marketplace.md`(0.1.4 发布记录)。
34
+ - [x] marketplace 条目不需要改 YAML(版本随 npm 自动更新,卡片显示 0.1.4)。