dsh-session-mng 0.1.2 → 0.1.4

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.0,实测宿主 DSH 0.2.1-rc.2(Desktop `desktop` profile)。本文面向代码评审与二次开发,记录可观察行为、事务边界、版本敏感点和已知限制。
3
+ > 对应版本 0.1.3,实测宿主 DSH 0.2.1-rc.3(Desktop `desktop` profile;0.2.1-rc.2 亦验证过)。本文面向代码评审与二次开发,记录可观察行为、事务边界、版本敏感点和已知限制。
4
4
 
5
5
  ## 1. 设计约束
6
6
 
@@ -43,13 +43,13 @@
43
43
 
44
44
  | 路由 | 说明 |
45
45
  |---|---|
46
- | `GET /state?subagents=0\|1` | 总览;默认过滤子会话 |
46
+ | `GET /state?subagents=0\|1` | 总览;默认过滤子代理会话(分支会话始终返回) |
47
47
  | `POST /preview` | 最近消息(最多 10 条) |
48
48
  | `POST /title/propose` | 整段会话标题提案(只读) |
49
49
  | `POST /title/apply` | 确认写入标题;live 或 cold |
50
50
  | `POST /archive` | `{sessionIds, stopActivity?}` |
51
51
  | `POST /unarchive` | `{sessionIds}` |
52
- | `POST /delete/preview` | 删除范围预览(根 + 子代理后代) |
52
+ | `POST /delete/preview` | 删除范围预览(根 + 子代理后代 + 分支提示) |
53
53
  | `POST /delete` | `{sessionIds, confirm:true}` |
54
54
  | `POST /move` | `{sessionIds, workspaceId}` |
55
55
 
@@ -65,12 +65,20 @@
65
65
  4. `sessionQuery.readTitleSnapshots(ids)`:只为缺失标题的行补读。
66
66
  5. `archivedSessionIds` / `pinnedSessionIds`。
67
67
 
68
+ 会话按三类打标(`kind`),这是 0.1.3 修正的核心:**不能用「有 `parentSession`」判定子会话**,因为分支会话(fork)也带 parent。
69
+
70
+ - `root`:无 parent;
71
+ - `fork`:非子代理且有 parent(`isSeeded:true`、`delegationDepth:0` 为辅证)。它是用户会话,默认可见、可改标题/归档/删除,但不可单独移动;
72
+ - `subagent`:`origin:'subagent'` 或 `delegationDepth≥1`(委派产生)。`subagents=0` 时过滤,并计入 `totals.hiddenSubagents` 供 UI 提示。
73
+
74
+ 行字段:`kind` / `isFork` / `isSubagent`(兼容)/ `parentSessionId` / `parentTitle` / `parentPresent` / `forkCount`;`totals` 附带 `forks` / `hiddenSubagents`。
75
+
68
76
  每个会话行带 `can`:
69
77
 
70
78
  - `title`:有 `llm`;
71
79
  - `archive`:未归档;
72
80
  - `restore`:已归档;
73
- - `move`:JSONL + zstd + workspace 缓存 seam 可用 + 非 live/running/子会话/有 parent;
81
+ - `move`:JSONL + zstd + workspace 缓存 seam 可用 + 非 live/running + `kind === 'root'`(分支要留在父会话工作区);
74
82
  - `delete`:JSONL + 已归档 + 非 live;
75
83
  - `open`:总是 true(客户端再判断 `uiWorkspace` 是否存在)。
76
84
 
@@ -110,8 +118,8 @@
110
118
  1. `capabilities.move`:JSONL + zstd + `canReconcileMove(registry)`。
111
119
  2. `stat(id)` 存在,header `cwd` 非空。
112
120
  3. 非 live(`ctx.sessions.get(id) === undefined`);非 running。
113
- 4. `header.origin !== 'subagent'` 且无 `parentSession`。
114
- 5. `traceSession(id)` 无 descendants。
121
+ 4. 子代理会话(`origin:'subagent'` 或 `delegationDepth≥1`)不能单独移动;普通分支(含历史 fork)是独立会话,不阻止移动。
122
+ 5. `traceSession(id)` 无**子代理** descendants(普通分支会出现在谱系里,但不再阻止移动)。
115
123
  6. 目标 workspace 存在且 `status() === 'ok'`;目标目录不存在。
116
124
  7. 源目录唯一;目标目录由 `locate({id,cwd:target.path})` 推导。
117
125
  8. `open(id,'write')` 成功后再 close,证明没有其他进程正在写。
@@ -159,19 +167,20 @@ targetDir = dirname(locate({id, cwd: target.path}))
159
167
  ### 9.1 预览
160
168
 
161
169
  - 每个 root 必须已归档。
162
- - `traceSession(root)` 递归收集 descendants;后代不要求已归档。
163
- - 每个 id:找目录、递归算字节、尝试 `readSession` 标记可读/损坏。
164
- - 返回 roots、descendantCount、sessions、totalBytes、errors。
170
+ - `traceSession(root)` 递归收集 descendants,并按 header 拆成两类:**子代理**(`origin:'subagent'` / `delegationDepth≥1`,随 root 级联)与**普通分支**(历史 fork 仍带 `parentSession`)。遇到分支即停止下钻,分支的子代不随 root 删除。
171
+ - 每个待删 id:找目录、递归算字节、尝试 `readSession` 标记可读/损坏;分支只读标题,供「不会被删除」提示使用。
172
+ - 返回 roots、descendantCount(仅子代理)、sessions、forkChildren、totalBytes、errors。
165
173
 
166
174
  ### 9.2 执行
167
175
 
168
176
  1. `confirm === true`;重新校验 root 仍已归档(防 TOCTOU)。
169
- 2. 重算 roots + descendants;总 id 数 > 500 拒绝。
177
+ 2. 重算 roots + **子代理** descendants;普通分支不参与;总 id 数 > 500 拒绝。
170
178
  3. 任一会话 live 或 running → 拒绝(live 会在内存留下幽灵行)。
171
179
  4. 每个存在的目录 `rename` 到 `tempRoot/delete-<batch>-<id>-<rand>`;记录映射。
172
180
  5. 清理 registry:`unarchiveSession`、所属 workspace `detachSession`、可选删除 headers/sessionPaths 缓存。
173
181
  6. 成功 → `rm` 所有 trash 目录并验证;失败 → 把 trash 移回原位,并尽力 `attachSession` + `archiveSession` 恢复账本,返回错误。
174
182
  7. 若最终 rm 失败 → `leftover-trash`,details 给出残留路径;不谎报成功。
183
+ 8. 保留下来的分支 header 仍记录已删除的父 id;宿主 0.2.1-rc.3 把这类历史 fork 当独立会话,插件列表用 `parentPresent:false` 显示「来源父会话已不存在」。
175
184
 
176
185
  ## 10. 安全
177
186
 
@@ -187,16 +196,17 @@ targetDir = dirname(locate({id, cwd: target.path}))
187
196
  |---|---|
188
197
  | `test/title.test.js` | 清洗、UTF-8 截断、turn 抽取、预算收缩 |
189
198
  | `test/store-jsonl.test.js` | header 解析、cwd 重写、后续字节不变、generation 选择、stage/rollback、删除 round-trip |
190
- | `test/state.test.js` | 工作区归属、归档/置顶、子会话过滤、orphans、capabilities |
191
- | `test/service.test.js` | 移动成功、workspace seam 失败回滚、删除成功/拒绝、冷标题事件形状 |
199
+ | `test/state.test.js` | 工作区归属、归档/置顶、三态分类(根/分支/子代理)、`parentTitle`/`parentPresent`、orphans、capabilities |
200
+ | `test/service.test.js` | 移动成功、workspace seam 失败回滚、删除成功/拒绝、删除保留普通分支、分支不阻止移动、子代理仍阻止移动、冷标题事件形状 |
192
201
  | `test/api.test.js` | fence、state 信封、未知 action、方法、坏 JSON、超大 body、错误码 |
193
202
 
194
- `node --test` 26 个测试全绿。
203
+ `node --test` 29 个测试全绿。
195
204
 
196
205
  ## 12. 已知限制与 0.2.0 方向
197
206
 
198
207
  - live 会话不能移动/删除(重启后重试)。
199
208
  - 有子代理后代的会话不能单独移动(0.2.0 整枝移动)。
209
+ - 普通分支(含历史 fork)不随父会话删除,会失去来源标识。宿主「普通 fork 不再写 `parentSession`」的变更在 DSH 源码里已合入,但**尚未进入当前安装的 0.2.1-rc.3 构建**(2026-10-28 实测:新建 fork 仍带 `parentSession`,app.asar 里仍有旧写入代码),所以这类「历史分支」形态短期仍会持续出现;插件对两种形态一视同仁。
200
210
  - 移动是整目录复制,大日志会慢;0.2.0 评估 rename/增量。
201
211
  - 非 JSONL 后端不支持移动/删除。
202
212
  - workspace 缓存 seam 是版本敏感点;升级 DSH 后先跑测试与实机验收。
@@ -132,3 +132,18 @@ curl -s https://awesome-dsh-plugin.com/plugins.json | grep -i session-mng
132
132
  ## 8. 接手
133
133
 
134
134
  下一步市场验证与 0.2.0 任务见 [HANDOVER-2026-10-27.md](HANDOVER-2026-10-27.md)。
135
+
136
+ ## 9. 0.1.2 更新发布(2026-10-27)
137
+
138
+ - 客户端修复:空会话默认隐藏 + 清理已归档空会话 + 删除范围模态确认。
139
+ - `npm publish`:`dsh-session-mng@0.1.2`,33 个文件,568.3 kB / unpacked 789.1 kB;约 3 分钟后 packument 出现,`latest` = 0.1.2,版本端点 200。
140
+ - Git:main 已 push;tag `v0.1.2`;Release 见仓库 Releases。
141
+ - marketplace 条目不变;卡片版本随 npm 自动更新,PR gate 仍只差仓库满 1 天。
142
+
143
+ ## 10. 0.1.3 更新发布(2026-10-28)
144
+
145
+ - 功能修复:分支会话三态模型(根/分支/子代理)、分支默认可见并缩进;删除父会话不再级联普通分支;父会话有历史分支时也能移动;兼容声明增加 `0.2.1-rc.3`。
146
+ - `npm publish`:`dsh-session-mng@0.1.3`,36 个文件,579.7 kB / unpacked 821.7 kB,shasum `e524c50988bb55a97b37ed08b6d94abcfc372b03`;约 1 分钟后 packument 出现 0.1.3,`latest` = 0.1.3。
147
+ - Git:main 已 push(功能提交 `a827495`);tag `v0.1.3`;Release https://github.com/ZilongYang/dsh-session-mng/releases/tag/v0.1.3
148
+ - marketplace:条目不需要改 YAML,卡片版本随 npm 自动更新;PR [#9672](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/9672) 仍只差仓库年龄 gate。
149
+ - 遗留:当前安装的 DSH `0.2.1-rc.3` 构建仍写 fork 的 `parentSession`(「独立 fork」变更只在 DSH 源码里),所以历史分支形态会继续出现,0.1.3 的删除/移动策略长期有效。
@@ -6,14 +6,14 @@
6
6
 
7
7
  | 项 | 值 |
8
8
  |---|---|
9
- | 版本 | `0.1.1`(npm `latest`) |
9
+ | 版本 | `0.1.2`(npm `latest`) |
10
10
  | npm | https://www.npmjs.com/package/dsh-session-mng |
11
11
  | GitHub | https://github.com/ZilongYang/dsh-session-mng |
12
- | Release | https://github.com/ZilongYang/dsh-session-mng/releases/tag/v0.1.1 |
13
- | 当前 main | 发布代码/功能提交 `d36e725`、`cea99f9`;交接后的文档提交以远端 `main` 为准 |
14
- | Tag | `v0.1.1` → `1b6a07f` |
12
+ | Release | https://github.com/ZilongYang/dsh-session-mng/releases/tag/v0.1.2 |
13
+ | 当前 main | 0.1.2 代码提交 `8feaf3a`;交接后的文档提交以远端 `main` 为准 |
14
+ | Tag | `v0.1.2`(最新发布) |
15
15
  | 测试 | `node --test` 26 个全绿 |
16
- | 包内容 | `npm pack` 31 个文件 / 560.8 kB;发布 tarball 560.7 kB |
16
+ | 包内容 | `npm pack` 33 个文件 / 568.3 kB;unpacked 789.1 kB |
17
17
  | 本地安装 | 已作为 symlink 安装到 Desktop `desktop` profile;改 client 后刷新页面即可,Host 改动需重启 DSH |
18
18
  | 市场 PR | awesome-dsh-plugin [#9672](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/9672),open |
19
19
 
@@ -120,7 +120,7 @@ gh release create v<version> --repo ZilongYang/dsh-session-mng --title "dsh-sess
120
120
  - 测试/截图只用一次性测试会话或 `/Users/demo/...` + `session-demo-*` 假数据;不要提交真实标题/路径/会话 id。
121
121
  - 公开计划是脱敏版;逐字原文在 `private/raw-plans/`(gitignore,不进 npm)。
122
122
  - 0.1.1 的 npm tarball 是在最终文档回填前打的,包内 checklist 可能显示 pending;功能/README 无影响。不值得为这个单独发 0.1.2。
123
- - GitHub tag `v0.1.1` 指向 `1b6a07f`;之后只有文档提交 `7e1b457`,代码一致。
123
+ - 0.1.2 的 npm 发布同样先返回 202,约 3 分钟后公开;tarball 33 个文件。
124
124
 
125
125
  ## 6. 关键文档
126
126
 
@@ -0,0 +1,116 @@
1
+ # 计划:分支会话(fork)默认可见 + 三态会话模型
2
+
3
+ > 提出:2026-10-28 · 批准:2026-10-28 · 对应版本:0.1.3
4
+ > 本文按批准内容原样归档;实施完成后差异另起一节补记(见文末)。
5
+
6
+ ## 1. 问题
7
+
8
+ 截图(2026-10-28,DSH 0.2.1-rc.2):
9
+
10
+ - 左侧工作区 `dsh-ml-input` 下有 2 条会话:`DSH 多行输入插件开发`(父,14 小时)和 `DSH 多行输入插件开发 (1)`(分支,2 小时)。
11
+ - 设置页「会话管理」的同一工作区只有 **1 个会话**,分支被隐藏;页面上「显示子会话」默认未勾选。
12
+
13
+ ## 2. 根因
14
+
15
+ `lib/state.js` 只有一条二元判断:
16
+
17
+ ```js
18
+ function isSubagent(header, summary) {
19
+ if (header?.origin === 'subagent' || summary?.origin === 'subagent') return true
20
+ return Boolean(header?.parentSession ?? summary?.parentSessionId) // 有父就算子会话
21
+ }
22
+ ```
23
+
24
+ `buildState` 在 `includeSubagents=false`(默认)时直接丢弃这些行。
25
+
26
+ 扫描 `~/.dsh/sessions` 全部 304 个会话 header:
27
+
28
+ | header 特征 | 数量 | 语义 |
29
+ |---|---|---|
30
+ | 无 origin / 无 parent / `isSeeded:false` | 165 | 普通根会话 |
31
+ | `origin:'subagent'` / `delegationDepth:1` / 有 parent | 137 | 子代理(委派,内部) |
32
+ | `isSeeded:true` / `delegationDepth:0` / 有 parent / 无 origin | 1 | **用户分支会话** |
33
+ | `isSeeded:true` / 无 parent | 1 | 无父种子会话 |
34
+
35
+ 实测落盘的分支是 `session-a9fdeaf7…`(2026-10-28 14:04 从 `session-aa896e13…` 分出)。线上 API 返回 `{"origin":null,"parentSessionId":"session-aa896e13…","isSubagent":true}` —— 即只要挂 parent 就被当成子代理。
36
+
37
+ DSH 自身的语义:分支是挂在父会话下、标题带 `(1)` 后缀的**一等用户会话**(可继续对话、改标题、归档);子代理不进入侧栏。插件把两者混为一类,属于判错。
38
+
39
+ ## 3. 决策
40
+
41
+ - **方案 B(三态 + 树形缩进 + 隐藏提示)**:修分类 + 分支默认显示并缩进挂在父行下 + 子代理仍默认隐藏但给出「已隐藏 N 个」提示。
42
+ - **分支会话暂不允许移动**:父会话留在原工作区时,分支跨区会让侧栏层级散架。
43
+
44
+ ## 4. 设计
45
+
46
+ ### 4.1 三态模型
47
+
48
+ | kind | 判定 | 默认可见 | 徽标 | 可管理动作 |
49
+ |---|---|---|---|---|
50
+ | `root` | 无 parent | 是 | — | 标题 / 归档 / 恢复 / 删除 / 移动 |
51
+ | `fork` | 非子代理且有 parent(`isSeeded:true` 为辅证) | **是** | 「分支」+ 父行「N 分支」计数 | 标题 / 归档 / 恢复 / 删除;**不可移动** |
52
+ | `subagent` | `origin:'subagent'` 或 `delegationDepth≥1` | 否(开关 + 「已隐藏 N 个子代理会话」提示) | 「子代理」 | 查看为主;删除随父级联 |
53
+
54
+ 向后兼容:保留 `isSubagent` 字段(= kind==='subagent'),新增 `isFork`、`kind`、`parentTitle`、`totals.hiddenSubagents`、`totals.forks`。
55
+
56
+ ### 4.2 列表呈现
57
+
58
+ - 同一工作区内,分支行**缩进**(每层 18px)紧跟父行;父行徽标区显示「N 分支」。
59
+ - 分支行标题前加 `↳`,副行显示「↳ 来自:父标题 · cwd · id」。
60
+ - 搜索命中父标题时,对应分支也命中。
61
+ - 详情页:分支徽标 + 「父会话」元数据行。
62
+ - 工具栏开关改名「显示子代理会话」;总览行在隐藏时提示「已隐藏 N 个子代理会话」。
63
+ - 移动不可用提示按原因区分:live / 子代理 / 分支 / 有后代。
64
+
65
+ ### 4.3 删除
66
+
67
+ 删除预览已列出根 + 子代理后代;本计划附带提示:若被删会话存在分支子项,预览里单独提示「分支会话不会随父删除,但会失去来源标识」。
68
+
69
+ ## 5. 改动清单
70
+
71
+ | 文件 | 改动 |
72
+ |---|---|
73
+ | `lib/state.js` | `classifyKind` 三态、`parentTitle` 回填、`totals.hiddenSubagents/forks`、`can.move` 仅 root |
74
+ | `lib/service.js` | 删除预览附带分支引用(可选);移动前置校验保持 |
75
+ | `client.js` | 块内父子排序、缩进 + 分支徽标、搜索含父标题、开关文案、隐藏提示、移动提示、详情页父会话行 |
76
+ | `test/state.test.js` | fork 用例:默认可见 / 可管理 / parentTitle / 不可移动;子代理隐藏计数 |
77
+ | `README.md` | 功能列表、已知限制 |
78
+ | `CHANGELOG.md` | 0.1.3 |
79
+ | `ROADMAP.md` | 0.1.3 delivered、后续候选整理 |
80
+ | `package.json` | 0.1.3 |
81
+ | `docs/RELEASE-CHECKLIST-0.1.3.md` | 发布检查清单 |
82
+
83
+ ## 6. 验收
84
+
85
+ 1. `npm test` 全绿(含新增 fork 用例)。
86
+ 2. 用 `~/.dsh` 真实数据构建 state:`dsh-ml-input` 分组从 1 条变 2 条,分支默认出现且 `isFork:true`、`parentTitle:'DSH 多行输入插件开发'`。
87
+ 3. GUI 刷新后:分支缩进显示、父行显示「1 分支」、总览行出现「已隐藏 N 个子代理会话」、勾选开关后子代理出现。
88
+ 4. 搜索父标题能找到分支;分支行 `can.move=false` 且提示为分支原因。
89
+ 5. 存量数据不需要迁移。
90
+
91
+ ## 7. 不在本次范围
92
+
93
+ - 子树折叠三角、只看主会话视图(方案 C,后续版本)。
94
+ - 分支会话移动、「父 + 分支」整枝移动。
95
+ - 0.2.0 的 Host 分页(另有计划)。
96
+
97
+ ## 8. 实施差异(事后补记,2026-10-28)
98
+
99
+ 计划外的实现细节:
100
+
101
+ 1. 状态行新增 `forkCount`(父行徽标「N 分支」),原计划打算只在客户端算。
102
+ 2. 删除预览实际实现了 `forkChildren`(原计划里标为「可选」)。
103
+ 3. `can.move` 直接写成 `row.kind === 'root'`(与「非子代理且无 parent」等价,读起来更直白)。
104
+ 4. 分支缩进深度由客户端 `orderBranchRows()` 计算(`branchDepth`),分支徽标用 `brand` 色调,`↳` 字形放在标题前。
105
+
106
+ 与原计划的差异:
107
+
108
+ - 计划文件名加了 `-branch-visibility` 后缀:ROADMAP 里已把 `docs/PLAN-2026-10-28.md` 预留给 0.2.0 的 Host 分页计划,避免撞名。
109
+ - 方案 C 的子树折叠、只看主会话视图按约定未做。
110
+
111
+ 验证记录:
112
+
113
+ - `npm test` 26/26 通过;`node --check index.js client.js lib/*.js test/*.js` 全部通过。
114
+ - 真实数据冒烟(`~/.dsh/sessions` 304 个日志 header + 临时 ctx 调 `buildState`):`totals {all:167, hiddenSubagents:137, forks:1}`;`dsh-ml-input` 的分支 `session-a9fdeaf7…` 判定为 `kind:'fork'`、`parentTitle:'DSH 多行输入插件开发'`,父行 `forkCount:1`。
115
+ - GUI 实机验收待重载 Desktop 后进行(见 `docs/RELEASE-CHECKLIST-0.1.3.md` B 节)。
116
+ - 版本 0.1.3,未发布。
@@ -0,0 +1,62 @@
1
+ # 计划:历史分支(legacy fork)的删除与移动安全
2
+
3
+ > 提出:2026-10-28 · 批准:2026-10-28 · 归入 0.1.3(未发布)
4
+ > 本文按批准内容原样归档;实施完成后差异另起一节补记。
5
+
6
+ ## 1. 背景
7
+
8
+ 0.1.3 修复了「分支默认可见」,但删除/移动仍按宿主的 `sessionQuery.traceSession` 收集后代。宿主 0.2.1-rc.3 已把**新建** fork 改为独立会话(不写 `parentSession`),而**历史 fork**(rc.2 及更早创建,header 保留 `parentSession`)在 `traceSession` 里仍算父会话的 descendant。
9
+
10
+ 实测(宿主同版本代码):
11
+
12
+ ```text
13
+ traceSession([p, fork1(parent=p), sub1(parent=p), grand(parent=fork1)], 'p')
14
+ → descendants: [fork1 → [grand], sub1]
15
+ ```
16
+
17
+ 插件现有行为因此有两个问题:
18
+
19
+ 1. `POST /delete` 会**级联删除历史分支**,且不要求分支已归档,也不单独确认;`POST /delete/preview` 把它标成「子代理会话」。
20
+ 2. `POST /move` 只要父会话有历史分支就 `has-descendants` 拒绝,属于误拦。
21
+
22
+ 当前库里有 4 条历史分支(1 条 v4 + 3 条 v0 老格式,内容都是真实用户对话)。
23
+
24
+ ## 2. 决策
25
+
26
+ - **删除:不级联历史分支。** 只级联 `origin:'subagent'` / `delegationDepth≥1` 的后代;历史分支保留,预览单独提示「不会被删除,但会失去来源标识;如需一起删请勾选」。
27
+ - **移动:解除误拦。** 只有子代理后代阻止移动;历史分支不再阻止,并在详情页提示「分支会留在原工作区」。
28
+ - 历史分支的父 id 悬空可以接受:rc.3 宿主把这类会话当独立根会话,打开/改名/归档不受影响;插件面板对这类行显示「来源父会话已不存在」。
29
+
30
+ ## 3. 实现
31
+
32
+ | 文件 | 改动 |
33
+ |---|---|
34
+ | `lib/state.js` | 抽出并导出 `classifySession(fields)`;行上增加 `parentPresent`(父行是否在本列表) |
35
+ | `lib/service.js` | `descendantsOf()` 改为返回 `{ subagents, forks }`(遇到 fork 停止下钻);`delete/preview` 只列出根 + 子代理,`forkChildren` 由谱系直接得出(排除同批被选中的分支);`remove()` 只删根 + 子代理;`moveOne()` 只按子代理后代判断 |
36
+ | `client.js` | 删除预览:分支保留提示 + 子代理计数;分支行/详情:父会话缺失提示;移动详情:有分支时的说明 |
37
+ | 测试 | `test/service.test.js` 覆盖「预览不列分支」「删除不删分支目录」「移动不被分支阻止」;`test/state.test.js` 覆盖 `parentPresent` |
38
+ | 文档 | CHANGELOG 0.1.3、README、ROADMAP、`docs/01-design.md`、`docs/RELEASE-CHECKLIST-0.1.3.md` |
39
+
40
+ ## 4. 验收
41
+
42
+ 1. `npm test` 全绿。
43
+ 2. 用假 ctx:`delete/preview` 返回的 `sessions` 不含历史分支、`forkChildren` 含它;`remove` 只删根 + 子代理目录,分支目录仍在。
44
+ 3. 真实数据只读核对:4 条历史分支仍出现在列表,`parentPresent` 正确。
45
+ 4. GUI 删除预览里出现「历史分支不会被删除」提示;父会话有分支时移动不再报 `has-descendants`。
46
+
47
+ ## 5. 不在本次范围
48
+
49
+ - 给历史分支补写/清理 `parentSession`(不在插件侧改 header)。
50
+ - 「父 + 分支」整枝迁移(ROADMAP Later)。
51
+ - 宿主 `traceSession` 的谱系语义(属于 DSH,不在插件范围)。
52
+
53
+ ## 6. 实施差异(事后补记,2026-10-28)
54
+
55
+ - `lib/state.js` 导出 `classifySession(fields)`,listing / delete / move 共用同一分类,避免三处各写一套 `origin/parentSession` 判断。
56
+ - 删除预览的 `forkChildren` 改为从同一次谱系遍历得到(原计划写的是沿用语料扫描),同批被勾选的分支不会重复出现在「保留」提示里。
57
+ - 行字段新增 `parentPresent`(父行是否还在本列表),客户端据此显示「来源父会话已不存在」;这是原计划「显示父缺失」的具体落点。
58
+ - 移动详情在 `movable && forkCount>0` 时多一条提示,说明分支留在原工作区;原计划只写了「解除误拦」。
59
+ - 测试从 26 个增加到 29 个;另跑了客户端无头渲染冒烟覆盖列表缩进、父缺失、移动提示与删除预览文案。
60
+ - 真实数据核对:当前库 4 条历史分支(`a9fdeaf7…`、`675ca5a4…`、`b9b7923d…`、`session-1`),第 2~4 条是 v0 老格式(`seedLength`、无 `isSeeded`),分类仍正确。
61
+ - 更正(2026-10-28 晚):本文第 1 节称「rc.3 已把新建 fork 改为独立会话」**不适用于当前安装的 0.2.1-rc.3 构建**。实测该构建的 app.asar 仍含 `parentSession: source.header.id` 写入路径,今天 17:42 新建的 fork 也带 `parentSession`;「独立 fork」变更只在 DSH 源码(master)里。因此「历史分支」形态仍会继续产生,本计划的保留/移动策略是长期需要,而非一次性兼容。
62
+ - 兼容声明按批准更新为 `0.2.1-rc.2` + `0.2.1-rc.3` 均 compatible。
@@ -19,13 +19,13 @@
19
19
  - 默认只渲染 2 条非空会话;tab 全部/活跃/已归档 = 2/2/0;`清理空会话` 按钮存在。
20
20
  - 打开清理 → `.dsm-scope-overlay` / `.dsm-scope-dialog` / `.dsm-scope-foot` 存在;确认按钮 disabled;取消后弹窗消失。
21
21
  - 勾选「显示空会话」→ 行数 4;再取消。
22
- - [ ] 子龙实机测试:默认隐藏、显示空会话、清理空会话(**不要确认删除真实空会话**,除非明确要清)、删除范围弹窗滚动时底部按钮可见。
22
+ - [x] 子龙实机测试通过:默认隐藏、显示空会话、清理空会话、删除范围弹窗滚动时底部按钮可见。
23
23
  - [ ] 若确认文案/按钮位置需要微调,再改一轮。
24
24
 
25
- ## C. 发布(待批准)
25
+ ## C. 发布(已完成)
26
26
 
27
- - [x] `npm pack --dry-run`:33 个文件 / 568.2 kB(比 0.1.1 多 2 个 docs)。
28
- - [ ] `npm publish` 0.1.2;验证版本端点与 latest。
29
- - [ ] Git commit / push / tag `v0.1.2` / GitHub Release。
30
- - [ ] 市场条目不需要改 YAML;版本随 npm 自动更新。
31
- - [ ] 更新 `CHANGELOG` 的 unreleased 标记、README 当前状态、ROADMAP、`docs/HANDOVER-2026-10-27.md`。
27
+ - [x] `npm pack`:33 个文件 / 568.3 kB,unpacked 789.1 kB。
28
+ - [x] `npm publish` 0.1.2;版本端点 HTTP 200,`latest` = 0.1.2,`dist.fileCount` = 33(发布后约 3 分钟公开)。
29
+ - [x] Git commit / push / tag `v0.1.2` / GitHub Release(见仓库 Releases)。
30
+ - [x] 市场条目不需要改 YAML;版本随 npm 自动更新。
31
+ - [x] 更新 `CHANGELOG` 的 unreleased 标记、README 当前状态、ROADMAP、`docs/HANDOVER-2026-10-27.md`。
@@ -0,0 +1,40 @@
1
+ # dsh-session-mng 0.1.3 发布检查清单
2
+
3
+ > 记录时间:2026-10-28。0.1.3 修复「分支会话被误判成子会话并默认隐藏」,并修正历史分支在删除/移动上的安全问题;Host 增加分类字段与计数,Client 增加缩进/徽标/提示。
4
+
5
+ ## A. 实现
6
+
7
+ - [x] `lib/state.js`:三态分类 `root` / `fork` / `subagent`;分支不再因 `parentSession` 被隐藏。
8
+ - [x] `lib/state.js`:行新增 `kind` / `isFork` / `parentTitle` / `forkCount`;`totals` 新增 `forks` / `hiddenSubagents`;`isSubagent` 保留兼容。
9
+ - [x] `lib/state.js`:`can.move` 改为仅 `root` 可移动。
10
+ - [x] `lib/service.js`:`descendantsOf()` 拆成子代理 / 普通分支;`delete` 只级联子代理,普通分支保留;`move` 只被子代理阻止。
11
+ - [x] `lib/service.js`:`delete/preview` 的 `forkChildren` 改由谱系直接得出(同批勾选的分支不重复提示)。
12
+ - [x] `lib/state.js`:导出 `classifySession()` 供 service 复用;行新增 `parentPresent`。
13
+ - [x] `client.js`:同一工作区内分支缩进挂在父行下方,父行显示「N 分支」,分支行显示「↳ 来自:父标题」。
14
+ - [x] `client.js`:搜索命中父标题时返回分支;总览行显示「已隐藏 N 个子代理会话」;开关改名「显示子代理会话」。
15
+ - [x] `client.js`:移动不可用提示区分 live / 分支 / 子代理 / 有后代;移动有分支的父会话时提示「分支留在原工作区」。
16
+ - [x] `client.js`:详情页显示分支徽标与「父会话」元数据行;来源父会话已删除时显示「来源父会话已不存在」。
17
+ - [x] `package.json` 兼容声明:`0.2.1-rc.2` + `0.2.1-rc.3` 均 `compatible`。
18
+ - [x] 中英双语文案同步。
19
+
20
+ ## B. 验证
21
+
22
+ - [x] `node --check index.js client.js lib/*.js test/*.js` 全部通过。
23
+ - [x] `npm test`:29 个测试全绿(fork 默认可见/`parentTitle`/`parentPresent`/`forkCount`/隐藏计数;删除级联子代理但保留普通分支;普通分支不阻止移动;子代理仍阻止移动)。
24
+ - [x] 真实数据冒烟(当时 `~/.dsh/sessions` 全量 420 个日志 header + 临时 ctx):库中 4 条历史分支(1 条 v4、3 条 v0 老格式);`dsh-ml-input` 的分支 `session-a9fdeaf7…` 判定 `kind:'fork'`、`parentTitle` 正确、父行 `forkCount:1`。(该分支随后在实机测试中被删除,见下条。)
25
+ - [x] 客户端无头渲染冒烟(最小 React shim + 假 state):
26
+ - 列表:分支缩进 18px、徽标「分支」、副行「↳ 来自:父标题」;父行「1 分支」;空会话默认隐藏;总览「已隐藏 N 个子代理会话」;勾选开关后以 `subagents=1` 重拉并显示子代理行。
27
+ - 详情:分支行显示「父会话」;来源父会话被删时行内显示「来源父会话已不存在」;父会话移动卡片提示「移动只迁移本会话,分支留在原工作区」。
28
+ - 删除预览:弹窗出现「N 个历史分支会话不会被删除…如需一起删除,请把它们也勾选进来」。
29
+ - [x] 现场旁证(应用已重载新 Host 代码,API 返回 `parentPresent`):`dsh-ml-input` 里父分支 `session-a9fdeaf7…` 已从列表消失,其分支 `session-64daeaa4…`(17:42 fork 出,标题「DSH 多行输入插件开发 (3)」)仍在,`parentPresent:false`、`parentTitle:''` —— 父被删后分支没有跟着消失。具体是否走插件删除流程待子龙确认。
30
+ - [ ] 子龙实机复核完整流程(重启/重载 Desktop 后):工作区从 1 条变 2 条,分支缩进在父会话下;勾选开关后子代理出现;搜索父标题命中分支;删除父会话的预览里出现分支保留提示;父会话移动不再报 `has-descendants`。
31
+ - [ ] 若缩进/徽标观感需要微调,再改一轮。
32
+
33
+ ## C. 发布(已完成)
34
+
35
+ - [x] `npm --cache /tmp/dsh-npm-cache --logs-dir /tmp/dsh-npm-logs pack --dry-run`:36 个文件 / 579.7 kB(unpacked 821.7 kB),shasum `e524c50988bb55a97b37ed08b6d94abcfc372b03`。
36
+ - [x] `npm publish` 0.1.3 成功;约 1 分钟后 packument 出现 0.1.3,`latest` = 0.1.3,`dist.fileCount` = 36。
37
+ - [x] Git:功能提交 `a827495`,main 已 push;tag `v0.1.3`;GitHub Release https://github.com/ZilongYang/dsh-session-mng/releases/tag/v0.1.3
38
+ - [x] 更新 CHANGELOG、README 当前状态、ROADMAP、`docs/02-open-source-and-marketplace.md`(0.1.3 发布记录)。
39
+ - [x] marketplace 条目不需要改 YAML(版本随 npm 自动更新;PR #9672 仍等仓库年龄 gate)。
40
+ - [ ] 子龙实机复核(见 B 节):缩进/徽标观感、删除预览分支提示、父会话移动。
@@ -0,0 +1,33 @@
1
+ # dsh-session-mng 0.1.4 发布检查清单
2
+
3
+ > 记录时间:2026-10-28。0.1.4 是详情页吸顶 + 操作提示与空会话入口三个交互修复,Host API 未变。
4
+
5
+ ## A. 实现
6
+
7
+ - [x] `client.js`:`.dsm-detail-head` 改为 `position: sticky`(不透明背景 + 底部分隔线),返回列表/标题/徽标/打开归档始终可见。
8
+ - [x] `client.js`:操作结果提示改为右下角浮层 toast(`position: fixed`),不随滚动消失。
9
+ - [x] `client.js`:纯成功提示 5 秒后自动隐藏;失败与带 action 的提示保留到手动关闭。
10
+ - [x] `client.js`:新增「归档空会话(N)」按钮(有未归档空会话时显示),按 `maxBatch` 分批归档并刷新。
11
+ - [x] `client.js`:「清理空会话(N)」改为只要有已归档空会话就显示;数量按全部已归档空会话计;存在已打开(不可删)项时禁用并提示「重启后才能清理」。
12
+ - [x] 中英双语文案(`archiveBlank` / `archiveBlankHint` / `cleanupBlankLockedHint`,移除 `blankActiveHint`)。
13
+ - [x] 页面头部重排:标题与三个操作按钮同一行(按钮右对齐),副标题/统计行在下方独占整行(`.dsm-head-row` + `margin-left:auto`)。
14
+ - [x] 删除范围预览与删除请求按 `maxBatch` 分批(空会话快捷清理可超过 50 条),结果合并成一个确认/一条提示。
15
+
16
+ ## B. 验证
17
+
18
+ - [x] `node --check client.js`;i18n 键覆盖检查(zh/en 各 164 项,引用键全部存在)。
19
+ - [x] `npm test`:29 个测试全绿(Host 未改)。
20
+ - [x] 客户端无头渲染冒烟(假 state:2 条未归档空会话 + 2 条已归档空会话,其中 1 条已打开):
21
+ - 头部出现「归档空会话(2)」与「清理空会话(2)」,清理按钮可用、悬停提示含「重启后才能清理」。
22
+ - 点击归档 → 发出 `POST /archive`(2 条),出现 toast「已归档 2 个会话。」。
23
+ - 等待 5.3 秒后 toast 自动消失。
24
+ - 头部结构为「标题行(标题 + 按钮)→ 副标题 → 统计行」;「归档空会话」「刷新」等按钮都在标题行内。
25
+ - 80 条已归档空会话:点「清理空会话(80)」→ 预览分 2 批(50+30)请求并合并显示「根会话: 80」;勾选确认后删除也分 2 批,toast 汇总「成功 80,失败 0」。
26
+ - [ ] 子龙实机:滚动详情页时标题行保持可见;操作后提示在右下角且自动消失;空会话按钮行为符合预期。
27
+
28
+ ## C. 发布(未开始)
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`。
package/lib/service.js CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { SessionMngError, messageOf } from './errors.js'
14
14
  import { normalizeTitle, proposeTitle, textOfEvent } from './title.js'
15
- import { buildState } from './state.js'
15
+ import { buildState, classifySession } from './state.js'
16
16
  import {
17
17
  defaultSessionsRoot,
18
18
  dirSize,
@@ -134,12 +134,30 @@ function normalizeIds(value, max, label = 'sessionIds') {
134
134
  return ids
135
135
  }
136
136
 
137
- /** Walk a lineage tree and collect every descendant session id. */
138
- function collectDescendants(node, out) {
137
+ /**
138
+ * Walk a lineage tree, splitting descendants into the two kinds the plugin
139
+ * treats differently.
140
+ *
141
+ * - `subagents` — delegation sessions; they belong to the root and are
142
+ * cascaded with it.
143
+ * - `forks` — ordinary branches (including historical logs that still record
144
+ * a parent). They are independent user sessions, so the walk records one and
145
+ * stops: nothing below it may be cascaded with the root.
146
+ *
147
+ * @param {object} node - one `SessionLineageNode` returned by `traceSession`.
148
+ * @param {{ subagents: object[], forks: object[] }} out - accumulator.
149
+ */
150
+ function partitionDescendants(node, out) {
139
151
  for (const child of node?.descendants ?? []) {
140
- const id = child?.session?.header?.id
141
- if (typeof id === 'string') out.push(id)
142
- collectDescendants(child, out)
152
+ const record = child?.session
153
+ const id = record?.header?.id
154
+ if (typeof id !== 'string') continue
155
+ if (classifySession(record.header) === 'subagent') {
156
+ out.subagents.push(record)
157
+ partitionDescendants(child, out)
158
+ } else {
159
+ out.forks.push(record)
160
+ }
143
161
  }
144
162
  return out
145
163
  }
@@ -200,7 +218,7 @@ export function createService(ctx, config) {
200
218
  }
201
219
  try {
202
220
  const lineage = await trace.call(query(), id)
203
- return collectDescendants(lineage, [])
221
+ return partitionDescendants(lineage, { subagents: [], forks: [] })
204
222
  } catch (error) {
205
223
  throw new SessionMngError('trace-failed', `could not trace descendants of "${id}": ${messageOf(error)}`, 500)
206
224
  }
@@ -441,9 +459,16 @@ export function createService(ctx, config) {
441
459
  throw new SessionMngError('unsupported-backend', 'permanent deletion requires the local JSONL session backend', 501)
442
460
  }
443
461
  const archived = new Set(archivedIds())
462
+ const selected = new Set(ids)
444
463
  const roots = []
445
464
  const errors = []
446
465
  const sessions = []
466
+ // Ordinary forks are independent sessions: they keep their own log when a
467
+ // parent is deleted and only lose the "from <parent>" label. Collect them
468
+ // separately so the confirmation can say so instead of silently cascading
469
+ // a user branch. A fork selected in this same batch is deleted on its own.
470
+ const forkChildren = []
471
+ const forkSeen = new Set()
447
472
  for (const rootId of ids) {
448
473
  try {
449
474
  if (!archived.has(rootId)) {
@@ -454,8 +479,18 @@ export function createService(ctx, config) {
454
479
  const snapshot = await store.stat(rootId)
455
480
  const header = snapshot?.header
456
481
  if (header === undefined) throw new SessionMngError('not-found', `session "${rootId}" is not stored`, 404)
457
- const descendants = await descendantsOf(rootId)
458
- const all = [rootId, ...descendants.filter((id) => id !== rootId)]
482
+ const lineage = await descendantsOf(rootId)
483
+ const all = [rootId, ...lineage.subagents.map((record) => record.header.id).filter((id) => id !== rootId)]
484
+ for (const record of lineage.forks) {
485
+ const forkId = record.header.id
486
+ if (selected.has(forkId) || forkSeen.has(forkId)) continue
487
+ forkSeen.add(forkId)
488
+ forkChildren.push({
489
+ id: forkId,
490
+ parentId: record.header.parentSession ?? rootId,
491
+ title: await readTitle(forkId),
492
+ })
493
+ }
459
494
  const rootRow = {
460
495
  id: rootId,
461
496
  title: await readTitle(rootId),
@@ -508,6 +543,7 @@ export function createService(ctx, config) {
508
543
  errors,
509
544
  sessions,
510
545
  descendantCount: roots.reduce((sum, root) => sum + root.descendantCount, 0),
546
+ forkChildren,
511
547
  totalBytes: sessions.reduce((sum, row) => sum + row.bytes, 0),
512
548
  }
513
549
  }
@@ -535,14 +571,17 @@ export function createService(ctx, config) {
535
571
  })
536
572
  continue
537
573
  }
538
- let descendants
574
+ let lineage
539
575
  try {
540
- descendants = await descendantsOf(rootId)
576
+ lineage = await descendantsOf(rootId)
541
577
  } catch (error) {
542
578
  failures.set(rootId, { sessionId: rootId, ok: false, error: resultError(error) })
543
579
  continue
544
580
  }
545
- for (const id of [rootId, ...descendants]) {
581
+ // Only delegation sessions are cascaded. An ordinary fork (including a
582
+ // historical log that still records a parent) is an independent user
583
+ // session and stays where it is.
584
+ for (const id of [rootId, ...lineage.subagents.map((record) => record.header.id)]) {
546
585
  if (seen.has(id)) continue
547
586
  seen.add(id)
548
587
  const snapshot = await store.stat(id).catch(() => undefined)
@@ -732,13 +771,22 @@ export function createService(ctx, config) {
732
771
  if (serviceOf(ctx, 'agents')?.get?.(id)?.status === 'running') {
733
772
  throw new SessionMngError('session-running', 'this session is running; stop it before moving', 409)
734
773
  }
735
- if (header.origin === 'subagent' || header.parentSession !== undefined) {
774
+ if (header.origin === 'subagent' || (typeof header.delegationDepth === 'number' && header.delegationDepth >= 1)) {
736
775
  throw new SessionMngError('subagent-session', 'subagent sessions are not moved on their own', 409)
737
776
  }
738
- const descendants = await descendantsOf(id)
739
- if (descendants.length > 0) {
777
+ if (header.parentSession !== undefined && header.parentSession !== null) {
778
+ throw new SessionMngError(
779
+ 'branch-session',
780
+ "a branch session must stay in its parent's workspace, so it is not moved on its own",
781
+ 409,
782
+ )
783
+ }
784
+ // Only subagent descendants block a move. An ordinary fork (historical or
785
+ // not) is an independent session, so it no longer blocks its source.
786
+ const lineage = await descendantsOf(id)
787
+ if (lineage.subagents.length > 0) {
740
788
  throw new SessionMngError('has-descendants', 'this session has subagent descendants; they cannot follow it yet', 409, {
741
- descendants: descendants.length,
789
+ descendants: lineage.subagents.length,
742
790
  })
743
791
  }
744
792
  const source = workspaceOfId(id)