dsh-session-mng 0.1.2 → 0.1.3
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/CHANGELOG.md +25 -1
- package/README.md +13 -6
- package/ROADMAP.md +11 -3
- package/client.js +116 -13
- package/docs/01-design.md +23 -13
- package/docs/02-open-source-and-marketplace.md +7 -0
- package/docs/HANDOVER-2026-10-27.md +6 -6
- package/docs/PLAN-2026-10-28-branch-visibility.md +116 -0
- package/docs/PLAN-2026-10-28-legacy-fork-safety.md +62 -0
- package/docs/RELEASE-CHECKLIST-0.1.2.md +7 -7
- package/docs/RELEASE-CHECKLIST-0.1.3.md +38 -0
- package/lib/service.js +64 -16
- package/lib/state.js +54 -9
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.3 — 2026-10-28
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **分支会话被误判成子会话并默认隐藏**:只要会话 header 带 `parentSession` 就被当成子代理会话,导致侧栏有 2 条、设置页「会话管理」只有 1 条。现在按三态分类——根会话 / 分支会话(`isSeeded:true`、无委派深度)/ 子代理会话(`origin:'subagent'` 或 `delegationDepth≥1`)。
|
|
8
|
+
- **删除父会话会连带删掉历史分支**:宿主 `traceSession` 仍按 `parentSession` 把历史分支算作后代,插件原先按该谱系级联删除,且分支不需归档、也没有单独确认。现在只级联子代理;历史分支保留(父链接悬空无害,父被删后宿主会把它降级为顶层会话),预览单独提示,勾选后才删除。
|
|
9
|
+
- **父会话有历史分支时不能移动**:`has-descendants` 误拦。现在只有子代理后代阻止移动。
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- 分支会话默认显示,并在所属工作区内缩进挂在父会话下方;父行显示「N 分支」计数,分支行副行显示「↳ 来自:父标题」。
|
|
14
|
+
- 搜索父标题时,其分支会话也会命中。
|
|
15
|
+
- 总览行显示「已隐藏 N 个子代理会话」;开关文案改为「显示子代理会话」。
|
|
16
|
+
- 删除范围预览提示:被删会话名下的 `N` 个分支不会被删除,但会失去来源标识;如需一起删除请勾选。
|
|
17
|
+
- 分支的来源父会话已被删除时,行/详情显示「来源父会话已不存在」。
|
|
18
|
+
- 移动有分支的父会话时,详情提示「移动只迁移本会话,分支留在原工作区并继续独立使用」。
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- 「子会话」文案统一改为「子代理」/「子代理会话」,与分支会话区分。
|
|
23
|
+
- 分支会话暂不支持跨工作区移动(保持父子同工作区),移动不可用提示按 live / 分支 / 子代理 / 有后代分别给出原因。
|
|
24
|
+
- `GET /state` 的行新增 `kind` / `isFork` / `parentTitle` / `parentPresent` / `forkCount`,`totals` 新增 `forks` / `hiddenSubagents`;`isSubagent` 保留用于兼容。
|
|
25
|
+
- 兼容声明增加 `0.2.1-rc.3: compatible`(保留 `0.2.1-rc.2`)。注意:当前安装的 rc.3 构建里普通 fork **仍会写** `parentSession`(DSH 源码中的「独立 fork」变更尚未进入该构建),所以「历史分支」形态短期仍会持续出现;插件对两种形态一视同仁。
|
|
26
|
+
|
|
27
|
+
## 0.1.2 — 2026-10-27
|
|
4
28
|
|
|
5
29
|
### Added
|
|
6
30
|
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-session-mng
|
|
2
2
|
|
|
3
|
+
> 官网 / Website: <https://dsh.zlmix.com/session-mng/>
|
|
4
|
+
|
|
3
5
|
[](https://www.npmjs.com/package/dsh-session-mng)
|
|
4
6
|
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
5
7
|
[](LICENSE)
|
|
@@ -14,7 +16,8 @@
|
|
|
14
16
|
|
|
15
17
|
## 功能
|
|
16
18
|
|
|
17
|
-
- **统一会话总览**:按工作区分组列出当前 profile 的全部会话(活跃 + 已归档)。显示标题、路径、会话 id
|
|
19
|
+
- **统一会话总览**:按工作区分组列出当前 profile 的全部会话(活跃 + 已归档)。显示标题、路径、会话 id、大小、最后活动时间、运行/已打开/已归档/已置顶/分支/子代理标记;支持搜索、工作区筛选、排序、刷新、批量选择。
|
|
20
|
+
- **分支会话默认显示**:从某条会话分出来的会话(fork)和主会话一样可见、可管理——缩进挂在父会话下方,父行显示「N 分支」,分支行标注「↳ 来自:父标题」;搜索父标题也能找到它。由委派产生的**子代理会话**仍默认隐藏(勾选「显示子代理会话」查看),总览行会提示「已隐藏 N 个子代理会话」。
|
|
18
21
|
- **工作区优先 + 分页**:会话数 >100 时默认折叠工作区;每个工作区默认 50 条,「加载更多」继续展开;搜索自动展开命中工作区。过滤工具栏和批量条整体吸顶。
|
|
19
22
|
- **选择跟随可见集**:切换筛选/折叠/搜索/刷新后,隐藏会话自动取消选择;`全选当前列表` 只作用于当前筛选与已展开的工作区。
|
|
20
23
|
- **空会话默认隐藏**:侧栏本来就不显示的 `blank` 会话,会话管理默认也不显示;勾选「显示空会话」才出现。提供「清理空会话(N)」快速清理已归档空会话。
|
|
@@ -22,7 +25,7 @@
|
|
|
22
25
|
- **生成标题**:选中任意会话(**包括已归档的冷会话**),让模型总结整段对话,给出可编辑的标题提案;**确认后才写入**。活跃会话走官方 `sessionTitle.rename`,冷会话按官方 `session/title` 事件形状追加,不需要恢复会话。
|
|
23
26
|
- **归档 / 恢复**:调用官方 `workspaceRegistry.archiveSession` / `unarchiveSession`。运行中的会话先提示,用户二次确认后才停止并归档;恢复后回到原工作区原位置。
|
|
24
27
|
- **跨工作区移动**:把冷会话真正迁移到目标工作区——改写会话日志 header 的 `cwd`、把日志目录迁到目标项目目录、同步 workspace 账本。SessionId、归档状态、pin 状态不变,事件字节不变。
|
|
25
|
-
- **彻底删除已归档会话**:删除前先预览范围(根会话、子代理会话、目录、字节数);确认后把日志目录移出 store、清理归档集合与 workspace 账本,再删除。**不可恢复。**
|
|
28
|
+
- **彻底删除已归档会话**:删除前先预览范围(根会话、子代理会话、目录、字节数);确认后把日志目录移出 store、清理归档集合与 workspace 账本,再删除。**不可恢复。** 子代理会话随父级联删除;**普通分支会话不会**(它们仍是独立会话,来源父会话删除后保留下来),如需一并删除请把分支也勾选进来。
|
|
26
29
|
- **预览最近消息**:不恢复会话即可看最近 10 条用户/助手消息。
|
|
27
30
|
- **失效归档标记清理**:归档集合里有、但磁盘上已无日志的 id,会以横幅提示并允许清理。
|
|
28
31
|
- **中英双语**、浅色/深色主题跟随宿主。
|
|
@@ -91,11 +94,11 @@ Host route (lib/api.js) ──► service (lib/service.js)
|
|
|
91
94
|
|
|
92
95
|
| 项 | 说明 |
|
|
93
96
|
|---|---|
|
|
94
|
-
| 实测宿主 | DSH `0.2.1-rc.
|
|
97
|
+
| 实测宿主 | DSH `0.2.1-rc.3`(Desktop `desktop` profile,Web GUI);`0.2.1-rc.2` 也已验证 |
|
|
95
98
|
| Node | `>=20.3` 可运行总览/标题/归档/删除;**移动** 需要 `node:zlib` 的 Zstandard 支持(Node `>=22.15`,当前 Desktop 运行时为 Node 24.21) |
|
|
96
99
|
| 会话后端 | 移动/删除只支持本地 JSONL 后端(`sessionPersistence.locate`);其他后端仍可用总览/标题/归档/恢复 |
|
|
97
100
|
| 官方依赖 | 无 runtime 依赖;只使用 `ctx` 服务 |
|
|
98
|
-
| 已知限制 | 已打开(live)会话不能移动或彻底删除,重启 DSH
|
|
101
|
+
| 已知限制 | 已打开(live)会话不能移动或彻底删除,重启 DSH 后重试;有子代理后代的会话不能单独移动;子代理会话默认隐藏(可勾选「显示子代理会话」查看);分支会话默认显示,但暂不能单独移动(需与父会话留在同一工作区);删除父会话不级联普通分支,分支会失去来源标识(显示「来源父会话已不存在」) |
|
|
99
102
|
| 规模限制 | 0.1.1 的折叠/分页只在客户端压 DOM,`state` 仍一次拉全量(202 条约 103 KB;10000 条约 5 MB)。Host 侧 overview + paged sessions 是 0.2.0 的主任务 |
|
|
100
103
|
|
|
101
104
|
### 版本敏感点(请读)
|
|
@@ -143,8 +146,9 @@ npm --cache /tmp/dsh-npm-cache pack --dry-run
|
|
|
143
146
|
|
|
144
147
|
## 当前状态与下一步
|
|
145
148
|
|
|
146
|
-
- `0.1.
|
|
147
|
-
- `0.1.2
|
|
149
|
+
- `0.1.3` 待发布:修复分支会话被当成子会话隐藏(三态会话模型 + 缩进显示 + 隐藏子代理提示),并修正「删除父会话级联删掉历史分支」「父会话因历史分支无法移动」两个问题;等实机验证后再发 npm/GitHub。
|
|
150
|
+
- `0.1.2` 已发布到 npm / GitHub / Release;空会话默认隐藏、清理空会话、删除范围模态确认。
|
|
151
|
+
- `0.1.1` 已发布;26 个测试全绿,0.1.2 包 33 个文件 / 568.3 kB。
|
|
148
152
|
- 市场 PR [#9672](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/9672) 等 1 天仓库年龄 gate,之后等维护者合并。
|
|
149
153
|
- 下一项主任务:**0.2.0 Host 侧 overview + 分页 sessions**,为 1000–10000 条会话设计;详细任务、验收标准、环境坑见 [接手说明](docs/HANDOVER-2026-10-27.md)。
|
|
150
154
|
|
|
@@ -156,6 +160,9 @@ npm --cache /tmp/dsh-npm-cache pack --dry-run
|
|
|
156
160
|
- [0.1.1 实施计划](docs/PLAN-2026-10-27.md)
|
|
157
161
|
- [0.1.1 发布检查清单](docs/RELEASE-CHECKLIST-0.1.1.md)
|
|
158
162
|
- [0.1.2 发布检查清单(待发布)](docs/RELEASE-CHECKLIST-0.1.2.md)
|
|
163
|
+
- [0.1.3 实施计划(分支会话默认可见)](docs/PLAN-2026-10-28-branch-visibility.md)
|
|
164
|
+
- [0.1.3 实施计划(历史分支删除/移动安全)](docs/PLAN-2026-10-28-legacy-fork-safety.md)
|
|
165
|
+
- [0.1.3 发布检查清单(待发布)](docs/RELEASE-CHECKLIST-0.1.3.md)
|
|
159
166
|
- [设计与实现说明](docs/01-design.md)
|
|
160
167
|
- [开源、发 npm 与市场收录记录](docs/02-open-source-and-marketplace.md)
|
|
161
168
|
- [路线图](ROADMAP.md)
|
package/ROADMAP.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Roadmap
|
|
2
2
|
|
|
3
|
-
> 当前:`0.1.
|
|
3
|
+
> 当前:`0.1.2` 已发布;`0.1.3`(分支会话默认可见)已实现待实机验证/发布。市场 PR [#9672](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/9672) 的 `check` 已过,只差仓库 1 天年龄 gate(约 2026-10-28 00:44 Asia/Shanghai 自动重跑)。下一项主任务是 0.2.0 的 Host 分页;详细接手任务见 [docs/HANDOVER-2026-10-27.md](docs/HANDOVER-2026-10-27.md)。
|
|
4
4
|
|
|
5
5
|
## 0.1.0 — delivered
|
|
6
6
|
|
|
@@ -19,12 +19,20 @@
|
|
|
19
19
|
- 选择集裁剪到当前筛选与展开可见集;批量操作不触碰隐藏会话。
|
|
20
20
|
- 批量移动提示区分 live 与一般不可移动项。
|
|
21
21
|
|
|
22
|
-
## 0.1.2 —
|
|
22
|
+
## 0.1.2 — delivered
|
|
23
23
|
|
|
24
24
|
- 空会话默认隐藏 + 「显示空会话」开关;tab 计数与可见列表一致。
|
|
25
25
|
- 「清理空会话(N)」批量清理已归档空会话,复用删除范围预览/确认。
|
|
26
26
|
- 删除范围确认改为模态框,内容独立滚动、底部确认区固定,支持遮罩/Esc 取消。
|
|
27
27
|
|
|
28
|
+
## 0.1.3 — delivered (待实机验证/发布)
|
|
29
|
+
|
|
30
|
+
- 三态会话模型:根 / 分支(fork)/ 子代理;分支不再因 `parentSession` 被误判成子会话隐藏。
|
|
31
|
+
- 分支会话默认显示,缩进挂在父行下,父行「N 分支」、分支行「↳ 来自:父标题」;搜索父标题命中分支。
|
|
32
|
+
- 子代理仍默认隐藏,总览行提示「已隐藏 N 个子代理会话」;开关改名「显示子代理会话」。
|
|
33
|
+
- 删除只级联子代理:历史分支保留(预览提示会失去来源标识),不再静默删除;父会话有历史分支时也能移动。
|
|
34
|
+
- 分支的来源父会话被删除后显示「来源父会话已不存在」;移动有分支的会话时提示分支留在原工作区。
|
|
35
|
+
|
|
28
36
|
## 0.2.0 — next (planned)
|
|
29
37
|
|
|
30
38
|
首要目标:真正支撑 1000–10000 条会话,不再首屏拉全量 `state`。实施前先写 `docs/PLAN-2026-10-28.md` 并获批准。
|
|
@@ -43,7 +51,7 @@
|
|
|
43
51
|
- 内容全文搜索(复用 `sessionQuery.searchSessions` / `searchEvents`),不只按标题/路径/id。
|
|
44
52
|
- 导出所选会话为 Markdown / JSON(删除前的安全网;批量)。
|
|
45
53
|
- 置顶 / 取消置顶(`workspaceRegistry.pinSession` / `unpinSession`)。
|
|
46
|
-
- 整枝移动(父会话 +
|
|
54
|
+
- 整枝移动(父会话 + 分支会话 + 子代理后代一起迁移),或在移动时保留父链接。
|
|
47
55
|
- 会话体检 / 修复:重复目录、幽灵归档、workspace 账本与 header cwd 不一致、损坏日志报告。
|
|
48
56
|
- 可选回收站(与「彻底删除」并存,默认关闭)。
|
|
49
57
|
- 移动的增量实现(rename 而不是整目录复制),大日志更快。
|
package/client.js
CHANGED
|
@@ -43,7 +43,8 @@ window.__ModuleLoader__.load({
|
|
|
43
43
|
sortCreated: '创建时间',
|
|
44
44
|
sortSize: '大小',
|
|
45
45
|
sortTitle: '标题',
|
|
46
|
-
showSubagents: '
|
|
46
|
+
showSubagents: '显示子代理会话',
|
|
47
|
+
showSubagentsHint: '子代理会话由委派产生,默认隐藏;分支会话始终显示。',
|
|
47
48
|
showBlank: '显示空会话',
|
|
48
49
|
cleanupBlank: '清理空会话({n})',
|
|
49
50
|
cleanupBlankHint: '批量删除已归档的空会话;未归档的空会话需先归档。',
|
|
@@ -70,7 +71,14 @@ window.__ModuleLoader__.load({
|
|
|
70
71
|
untitled: '(未命名)',
|
|
71
72
|
running: '运行中',
|
|
72
73
|
archived: '已归档',
|
|
73
|
-
subagent: '
|
|
74
|
+
subagent: '子代理',
|
|
75
|
+
branch: '分支',
|
|
76
|
+
fromParent: '↳ 来自:{title}',
|
|
77
|
+
hiddenSubagentCount: '已隐藏 {n} 个子代理会话',
|
|
78
|
+
forkCount: '{n} 分支',
|
|
79
|
+
forkCountHint: '该会话有 {n} 个分支会话',
|
|
80
|
+
parentSession: '父会话',
|
|
81
|
+
parentMissing: '来源父会话已不存在',
|
|
74
82
|
pinned: '已置顶',
|
|
75
83
|
liveBadge: '已打开',
|
|
76
84
|
noCwd: '无工作目录',
|
|
@@ -113,7 +121,9 @@ window.__ModuleLoader__.load({
|
|
|
113
121
|
moving: '正在移动…',
|
|
114
122
|
moveNoTarget: '没有可移动到的目标工作区。',
|
|
115
123
|
moveLiveHint: '该会话已在当前 DSH 进程中打开,重启 DSH 后再移动。',
|
|
116
|
-
moveSubagentHint: '
|
|
124
|
+
moveSubagentHint: '子代理会话不能单独移动。',
|
|
125
|
+
moveForkHint: '分支会话必须和父会话留在同一工作区,暂不支持单独移动。',
|
|
126
|
+
moveForkChildrenHint: '该会话有 {n} 个分支会话;移动只迁移本会话,分支会留在原工作区并继续独立使用。',
|
|
117
127
|
moveDescendantsHint: '该会话包含子代理会话,暂时不能单独移动。',
|
|
118
128
|
deleteAction: '彻底删除',
|
|
119
129
|
deleteHint: '只允许删除已归档会话;删除后日志目录与工作区记录都会被移除,无法恢复。',
|
|
@@ -130,6 +140,7 @@ window.__ModuleLoader__.load({
|
|
|
130
140
|
deleteNotArchived: '只有已归档会话可以彻底删除。',
|
|
131
141
|
deleteLiveHint: '该会话已在当前 DSH 进程中打开,重启 DSH 后再删除。',
|
|
132
142
|
deleteCascadeNote: '如果该会话有子代理会话,会一并删除。',
|
|
143
|
+
deleteForkNote: '另有 {n} 个历史分支会话不会被删除,但会失去「来自父会话」的来源标识;如需一起删除,请把它们也勾选进来。',
|
|
133
144
|
archiveAction: '归档',
|
|
134
145
|
archiving: '正在归档…',
|
|
135
146
|
archiveDone: '已归档 {n} 个会话。',
|
|
@@ -164,7 +175,7 @@ window.__ModuleLoader__.load({
|
|
|
164
175
|
timeout: '模型调用超时。',
|
|
165
176
|
llmError: '模型调用失败:{message}',
|
|
166
177
|
liveHint: '已打开的会话不能移动或彻底删除;重启 DSH 后重试。',
|
|
167
|
-
moveUnavailableHint: '
|
|
178
|
+
moveUnavailableHint: '所选会话中存在不可移动项(如跨工作区、分支/子代理会话或状态未就绪)。',
|
|
168
179
|
archiveHint: '归档后会话会从活跃列表移到已归档,可在本页恢复。',
|
|
169
180
|
moveArchivedHint: '移动不会改变归档状态,会话仍会留在已归档。',
|
|
170
181
|
working: '处理中…',
|
|
@@ -197,6 +208,7 @@ window.__ModuleLoader__.load({
|
|
|
197
208
|
sortSize: 'Size',
|
|
198
209
|
sortTitle: 'Title',
|
|
199
210
|
showSubagents: 'Show subagent sessions',
|
|
211
|
+
showSubagentsHint: 'Subagent sessions are produced by delegation and stay hidden by default; branch sessions are always listed.',
|
|
200
212
|
showBlank: 'Show empty sessions',
|
|
201
213
|
cleanupBlank: 'Clean up empty sessions ({n})',
|
|
202
214
|
cleanupBlankHint: 'Permanently delete archived empty sessions; unarchived empty sessions must be archived first.',
|
|
@@ -224,6 +236,13 @@ window.__ModuleLoader__.load({
|
|
|
224
236
|
running: 'Running',
|
|
225
237
|
archived: 'Archived',
|
|
226
238
|
subagent: 'Subagent',
|
|
239
|
+
branch: 'Branch',
|
|
240
|
+
fromParent: '↳ from: {title}',
|
|
241
|
+
hiddenSubagentCount: '{n} subagent sessions hidden',
|
|
242
|
+
forkCount: '{n} branch',
|
|
243
|
+
forkCountHint: 'This session has {n} branch sessions',
|
|
244
|
+
parentSession: 'Parent session',
|
|
245
|
+
parentMissing: 'Source session no longer exists',
|
|
227
246
|
pinned: 'Pinned',
|
|
228
247
|
liveBadge: 'Open',
|
|
229
248
|
noCwd: 'No working directory',
|
|
@@ -267,6 +286,8 @@ window.__ModuleLoader__.load({
|
|
|
267
286
|
moveNoTarget: 'No target workspace is available.',
|
|
268
287
|
moveLiveHint: 'This session is open in this DSH process; restart DSH and retry.',
|
|
269
288
|
moveSubagentHint: 'Subagent sessions cannot be moved on their own.',
|
|
289
|
+
moveForkHint: 'A branch session must stay in its parent\'s workspace, so it cannot be moved on its own.',
|
|
290
|
+
moveForkChildrenHint: 'This session has {n} branch sessions; moving migrates only this session, and the branches stay in the original workspace and keep working independently.',
|
|
270
291
|
moveDescendantsHint: 'This session has subagent descendants and cannot move alone yet.',
|
|
271
292
|
deleteAction: 'Delete permanently',
|
|
272
293
|
deleteHint: 'Only archived sessions can be deleted. The log directory and workspace record are removed; this cannot be undone.',
|
|
@@ -283,6 +304,7 @@ window.__ModuleLoader__.load({
|
|
|
283
304
|
deleteNotArchived: 'Only archived sessions can be permanently deleted.',
|
|
284
305
|
deleteLiveHint: 'This session is open in this DSH process; restart DSH and retry.',
|
|
285
306
|
deleteCascadeNote: 'Any subagent descendants are deleted too.',
|
|
307
|
+
deleteForkNote: '{n} branch sessions are not deleted, but they lose their "from parent" label. Select them too if they should be deleted as well.',
|
|
286
308
|
archiveAction: 'Archive',
|
|
287
309
|
archiving: 'Archiving…',
|
|
288
310
|
archiveDone: 'Archived {n} sessions.',
|
|
@@ -317,7 +339,7 @@ window.__ModuleLoader__.load({
|
|
|
317
339
|
timeout: 'The model call timed out.',
|
|
318
340
|
llmError: 'The model call failed: {message}',
|
|
319
341
|
liveHint: 'An open session cannot be moved or deleted; restart DSH and retry.',
|
|
320
|
-
moveUnavailableHint: 'Some selected sessions cannot be moved (cross-workspace, subagent
|
|
342
|
+
moveUnavailableHint: 'Some selected sessions cannot be moved (cross-workspace, branch or subagent sessions, or not ready).',
|
|
321
343
|
archiveHint: 'Archiving moves the session from the active list to the archived list; restore it here.',
|
|
322
344
|
moveArchivedHint: 'Moving does not change the archive state; the session stays archived.',
|
|
323
345
|
working: 'Working…',
|
|
@@ -403,6 +425,7 @@ window.__ModuleLoader__.load({
|
|
|
403
425
|
.dsm-row-main{display:flex;flex-direction:column;align-items:flex-start;gap:1px;min-width:0;
|
|
404
426
|
border:none;background:transparent;color:inherit;font:inherit;text-align:left;padding:0;cursor:pointer}
|
|
405
427
|
.dsm-row-title{max-width:100%;font-size:13px;line-height:19px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
428
|
+
.dsm-branch-glyph{margin-right:4px;font-size:12px;line-height:1;color:var(--dsw-alias-label-secondary);opacity:.85}
|
|
406
429
|
.dsm-row-meta{max-width:100%;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
407
430
|
.dsm-badges{display:flex;align-items:center;gap:4px;flex-wrap:wrap}
|
|
408
431
|
.dsm-badge{display:inline-flex;align-items:center;height:17px;padding:0 5px;border-radius:4px;font-size:10px;line-height:17px;
|
|
@@ -543,6 +566,7 @@ window.__ModuleLoader__.load({
|
|
|
543
566
|
if (code === 'session-running') return t('sessionRunning')
|
|
544
567
|
if (code === 'session-writer-held') return t('writerHeld')
|
|
545
568
|
if (code === 'has-descendants') return t('moveDescendantsHint')
|
|
569
|
+
if (code === 'branch-session') return t('moveForkHint')
|
|
546
570
|
if (code === 'archived-required') return t('deleteNotArchived')
|
|
547
571
|
if (code === 'unsupported-backend') return t('unsupportedBackend')
|
|
548
572
|
if (code === 'unsupported-registry') return t('unsupportedRegistry')
|
|
@@ -659,15 +683,28 @@ window.__ModuleLoader__.load({
|
|
|
659
683
|
}
|
|
660
684
|
|
|
661
685
|
function Row({ row, selected, onToggle, onOpen, t }) {
|
|
686
|
+
const depth = row.branchDepth ?? 0
|
|
687
|
+
const branch = row.kind === 'fork'
|
|
688
|
+
const forkCount = row.forkCount ?? 0
|
|
662
689
|
const badges = []
|
|
663
690
|
if (row.running) badges.push(h(Badge, { key: 'run', tone: 'run' }, t('running')))
|
|
664
691
|
if (row.archived) badges.push(h(Badge, { key: 'arch' }, t('archived')))
|
|
665
692
|
if (row.live) badges.push(h(Badge, { key: 'live', tone: 'brand' }, t('liveBadge')))
|
|
666
693
|
if (row.pinned) badges.push(h(Badge, { key: 'pin' }, t('pinned')))
|
|
694
|
+
if (branch) badges.push(h(Badge, { key: 'fork', tone: 'brand' }, t('branch')))
|
|
667
695
|
if (row.isSubagent) badges.push(h(Badge, { key: 'sub', tone: 'warn' }, t('subagent')))
|
|
696
|
+
if (forkCount > 0) {
|
|
697
|
+
badges.push(
|
|
698
|
+
h('span', { key: 'children', title: t('forkCountHint', { n: forkCount }) },
|
|
699
|
+
h(Badge, null, t('forkCount', { n: forkCount }))),
|
|
700
|
+
)
|
|
701
|
+
}
|
|
668
702
|
return h(
|
|
669
703
|
'div',
|
|
670
|
-
{
|
|
704
|
+
{
|
|
705
|
+
className: `dsm-row${selected === true ? ' selected' : ''}${depth > 0 ? ' dsm-row-branch' : ''}`,
|
|
706
|
+
style: depth > 0 ? { paddingLeft: `${depth * 18}px` } : undefined,
|
|
707
|
+
},
|
|
671
708
|
h('input', {
|
|
672
709
|
type: 'checkbox',
|
|
673
710
|
checked: selected === true,
|
|
@@ -677,10 +714,20 @@ window.__ModuleLoader__.load({
|
|
|
677
714
|
h(
|
|
678
715
|
'button',
|
|
679
716
|
{ type: 'button', className: 'dsm-row-main', onClick: onOpen, title: row.cwd || row.id },
|
|
680
|
-
h(
|
|
717
|
+
h(
|
|
718
|
+
'span',
|
|
719
|
+
{ className: 'dsm-row-title' },
|
|
720
|
+
depth > 0 ? h('span', { className: 'dsm-branch-glyph', 'aria-hidden': 'true' }, '↳') : null,
|
|
721
|
+
row.title === '' ? t('untitled') : row.title,
|
|
722
|
+
),
|
|
681
723
|
h(
|
|
682
724
|
'span',
|
|
683
725
|
{ className: 'dsm-row-meta' },
|
|
726
|
+
branch && row.parentTitle !== ''
|
|
727
|
+
? `${t('fromParent', { title: row.parentTitle })} · `
|
|
728
|
+
: branch && row.parentPresent === false
|
|
729
|
+
? `${t('parentMissing')} · `
|
|
730
|
+
: '',
|
|
684
731
|
row.cwd === '' ? t('noCwd') : row.cwd,
|
|
685
732
|
' · ',
|
|
686
733
|
row.id,
|
|
@@ -1016,6 +1063,9 @@ window.__ModuleLoader__.load({
|
|
|
1016
1063
|
h('div', { className: 'dsm-hint' },
|
|
1017
1064
|
`${t('deleteRoots')}: ${preview?.roots?.length ?? 0} · ${t('deleteDescendants')}: ${preview?.descendantCount ?? 0} · ${t('deleteTotalSize', { size: formatBytes(preview?.totalBytes ?? 0) })}`,
|
|
1018
1065
|
),
|
|
1066
|
+
(preview?.forkChildren?.length ?? 0) > 0
|
|
1067
|
+
? h('p', { className: 'dsm-hint' }, t('deleteForkNote', { n: preview.forkChildren.length }))
|
|
1068
|
+
: null,
|
|
1019
1069
|
h('div', { className: 'dsm-scope-list' },
|
|
1020
1070
|
rows.map((row) =>
|
|
1021
1071
|
h(
|
|
@@ -1118,6 +1168,7 @@ window.__ModuleLoader__.load({
|
|
|
1118
1168
|
row.archived ? h(Badge, null, t('archived')) : null,
|
|
1119
1169
|
row.live ? h(Badge, { tone: 'brand' }, t('liveBadge')) : null,
|
|
1120
1170
|
row.pinned ? h(Badge, null, t('pinned')) : null,
|
|
1171
|
+
row.kind === 'fork' ? h(Badge, { tone: 'brand' }, t('branch')) : null,
|
|
1121
1172
|
row.isSubagent ? h(Badge, { tone: 'warn' }, t('subagent')) : null,
|
|
1122
1173
|
),
|
|
1123
1174
|
),
|
|
@@ -1139,6 +1190,16 @@ window.__ModuleLoader__.load({
|
|
|
1139
1190
|
{ className: 'dsm-meta' },
|
|
1140
1191
|
h('dt', null, t('workspace')),
|
|
1141
1192
|
h('dd', null, row.workspaceTitle || t('workspaceUngrouped')),
|
|
1193
|
+
row.kind === 'fork'
|
|
1194
|
+
? h(React.Fragment, null,
|
|
1195
|
+
h('dt', null, t('parentSession')),
|
|
1196
|
+
h('dd', null,
|
|
1197
|
+
row.parentTitle !== ''
|
|
1198
|
+
? row.parentTitle
|
|
1199
|
+
: row.parentPresent === false
|
|
1200
|
+
? t('parentMissing')
|
|
1201
|
+
: (row.parentSessionId || '')))
|
|
1202
|
+
: null,
|
|
1142
1203
|
h('dt', null, t('path')),
|
|
1143
1204
|
h('dd', { title: row.cwd }, h('span', { className: 'dsm-mono' }, row.cwd || t('noCwd'))),
|
|
1144
1205
|
h('dt', null, t('id')),
|
|
@@ -1168,9 +1229,12 @@ window.__ModuleLoader__.load({
|
|
|
1168
1229
|
h('h3', null, t('moveTo')),
|
|
1169
1230
|
h('p', { className: 'dsm-hint', style: { margin: '0 0 8px' } }, t('moveHint')),
|
|
1170
1231
|
row.archived ? h('p', { className: 'dsm-hint', style: { margin: '0 0 8px' } }, t('moveArchivedHint')) : null,
|
|
1232
|
+
movable && (row.forkCount ?? 0) > 0
|
|
1233
|
+
? h('p', { className: 'dsm-hint', style: { margin: '0 0 8px' } }, t('moveForkChildrenHint', { n: row.forkCount }))
|
|
1234
|
+
: null,
|
|
1171
1235
|
!movable
|
|
1172
1236
|
? h('p', { className: 'dsm-hint' },
|
|
1173
|
-
row.live ? t('moveLiveHint') : row.isSubagent ? t('moveSubagentHint') : row.can?.move === false ? t('moveDescendantsHint') : t('moveNoTarget'))
|
|
1237
|
+
row.live ? t('moveLiveHint') : row.kind === 'fork' ? t('moveForkHint') : row.isSubagent ? t('moveSubagentHint') : row.can?.move === false ? t('moveDescendantsHint') : t('moveNoTarget'))
|
|
1174
1238
|
: h(
|
|
1175
1239
|
'div',
|
|
1176
1240
|
{ className: 'dsm-title-row' },
|
|
@@ -1221,6 +1285,38 @@ window.__ModuleLoader__.load({
|
|
|
1221
1285
|
// Page
|
|
1222
1286
|
// ---------------------------------------------------------------------
|
|
1223
1287
|
|
|
1288
|
+
/**
|
|
1289
|
+
* Order one group's rows so a branch sits directly under its parent, the
|
|
1290
|
+
* way the sidebar nests it. A branch whose parent is filtered out of this
|
|
1291
|
+
* group stays at the top level and keeps its "from parent" meta line.
|
|
1292
|
+
*/
|
|
1293
|
+
function orderBranchRows(rows) {
|
|
1294
|
+
const ids = new Set(rows.map((row) => row.id))
|
|
1295
|
+
const childrenOf = new Map()
|
|
1296
|
+
const tops = []
|
|
1297
|
+
for (const row of rows) {
|
|
1298
|
+
const parentId = row.kind === 'fork' ? row.parentSessionId : null
|
|
1299
|
+
if (parentId !== null && parentId !== row.id && ids.has(parentId)) {
|
|
1300
|
+
const list = childrenOf.get(parentId) ?? []
|
|
1301
|
+
list.push(row)
|
|
1302
|
+
childrenOf.set(parentId, list)
|
|
1303
|
+
} else {
|
|
1304
|
+
tops.push(row)
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
const out = []
|
|
1308
|
+
const visited = new Set()
|
|
1309
|
+
const visit = (row, depth) => {
|
|
1310
|
+
if (visited.has(row.id)) return
|
|
1311
|
+
visited.add(row.id)
|
|
1312
|
+
out.push(depth === 0 ? row : { ...row, branchDepth: depth })
|
|
1313
|
+
for (const child of childrenOf.get(row.id) ?? []) visit(child, depth + 1)
|
|
1314
|
+
}
|
|
1315
|
+
for (const row of tops) visit(row, 0)
|
|
1316
|
+
// A corrupt parent cycle must not silently drop rows from the list.
|
|
1317
|
+
return out.length === rows.length ? out : rows
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1224
1320
|
/**
|
|
1225
1321
|
* Derive the filtered / grouped / rendered view for the list.
|
|
1226
1322
|
* Pure so the selection-pruning effect can use it before the loading guard.
|
|
@@ -1237,7 +1333,7 @@ window.__ModuleLoader__.load({
|
|
|
1237
1333
|
if (workspaceFilter !== 'all' && workspaceFilter !== 'ungrouped' && row.workspaceId !== workspaceFilter) return false
|
|
1238
1334
|
if (showBlank !== true && row.blank === true) return false
|
|
1239
1335
|
if (filtering) {
|
|
1240
|
-
const haystack = `${row.title}\n${row.cwd}\n${row.id}`.toLowerCase()
|
|
1336
|
+
const haystack = `${row.title}\n${row.cwd}\n${row.id}\n${row.parentTitle ?? ''}`.toLowerCase()
|
|
1241
1337
|
if (!haystack.includes(text)) return false
|
|
1242
1338
|
}
|
|
1243
1339
|
return true
|
|
@@ -1250,7 +1346,7 @@ window.__ModuleLoader__.load({
|
|
|
1250
1346
|
})
|
|
1251
1347
|
const baseGroups = []
|
|
1252
1348
|
for (const workspace of workspaceOrder) {
|
|
1253
|
-
const groupRows = sorted.filter((row) => row.workspaceId === workspace.id)
|
|
1349
|
+
const groupRows = orderBranchRows(sorted.filter((row) => row.workspaceId === workspace.id))
|
|
1254
1350
|
if (groupRows.length === 0) continue
|
|
1255
1351
|
baseGroups.push({
|
|
1256
1352
|
...workspace,
|
|
@@ -1258,7 +1354,7 @@ window.__ModuleLoader__.load({
|
|
|
1258
1354
|
bytes: groupRows.reduce((sum, row) => sum + row.sizeBytes, 0),
|
|
1259
1355
|
})
|
|
1260
1356
|
}
|
|
1261
|
-
const ungroupedRows = sorted.filter((row) => row.workspaceId === null)
|
|
1357
|
+
const ungroupedRows = orderBranchRows(sorted.filter((row) => row.workspaceId === null))
|
|
1262
1358
|
if (ungroupedRows.length > 0) {
|
|
1263
1359
|
baseGroups.push({
|
|
1264
1360
|
id: 'ungrouped',
|
|
@@ -1647,6 +1743,9 @@ window.__ModuleLoader__.load({
|
|
|
1647
1743
|
blankRows.length > 0 && !showBlank
|
|
1648
1744
|
? ` · ${t('hiddenBlankCount', { n: blankRows.length })}`
|
|
1649
1745
|
: '',
|
|
1746
|
+
(state.totals?.hiddenSubagents ?? 0) > 0 && !includeSubagents
|
|
1747
|
+
? ` · ${t('hiddenSubagentCount', { n: state.totals.hiddenSubagents })}`
|
|
1748
|
+
: '',
|
|
1650
1749
|
),
|
|
1651
1750
|
blankActiveCount > 0 && showBlank
|
|
1652
1751
|
? h('p', { className: 'dsm-hint' }, t('blankActiveHint', { n: blankActiveCount }))
|
|
@@ -1726,7 +1825,7 @@ window.__ModuleLoader__.load({
|
|
|
1726
1825
|
h('option', { value: 'size' }, t('sortSize')),
|
|
1727
1826
|
h('option', { value: 'title' }, t('sortTitle')),
|
|
1728
1827
|
),
|
|
1729
|
-
h('label', { className: 'dsm-check' },
|
|
1828
|
+
h('label', { className: 'dsm-check', title: t('showSubagentsHint') },
|
|
1730
1829
|
h('input', {
|
|
1731
1830
|
type: 'checkbox',
|
|
1732
1831
|
checked: includeSubagents,
|
|
@@ -1802,7 +1901,11 @@ window.__ModuleLoader__.load({
|
|
|
1802
1901
|
}, t('clearSelection')),
|
|
1803
1902
|
!selectedMovable && selectedRows.length > 0
|
|
1804
1903
|
? h('span', { className: 'dsm-hint' },
|
|
1805
|
-
selectedRows.some((row) => row.live || row.running)
|
|
1904
|
+
selectedRows.some((row) => row.live || row.running)
|
|
1905
|
+
? t('liveHint')
|
|
1906
|
+
: selectedRows.some((row) => row.kind === 'fork')
|
|
1907
|
+
? t('moveForkHint')
|
|
1908
|
+
: t('moveUnavailableHint'))
|
|
1806
1909
|
: null,
|
|
1807
1910
|
)
|
|
1808
1911
|
: null,
|
package/docs/01-design.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-session-mng 设计与实现说明
|
|
2
2
|
|
|
3
|
-
> 对应版本 0.1.
|
|
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
|
|
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.
|
|
114
|
-
5. `traceSession(id)`
|
|
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
|
-
-
|
|
164
|
-
- 返回 roots、descendantCount
|
|
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
|
|
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` |
|
|
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`
|
|
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,10 @@ 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 天。
|
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
| 项 | 值 |
|
|
8
8
|
|---|---|
|
|
9
|
-
| 版本 | `0.1.
|
|
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.
|
|
13
|
-
| 当前 main |
|
|
14
|
-
| Tag | `v0.1.
|
|
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`
|
|
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
|
-
-
|
|
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
|
|
28
|
-
- [
|
|
29
|
-
- [
|
|
30
|
-
- [
|
|
31
|
-
- [
|
|
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,38 @@
|
|
|
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
|
+
- [ ] `npm --cache /tmp/dsh-npm-cache --logs-dir /tmp/dsh-npm-logs pack --dry-run` 检查文件清单。
|
|
36
|
+
- [ ] `npm publish` 0.1.3;确认版本端点 `latest` = 0.1.3。
|
|
37
|
+
- [ ] Git commit / push / tag `v0.1.3` / GitHub Release。
|
|
38
|
+
- [ ] 更新 CHANGELOG 的发布标记、README 当前状态、ROADMAP。
|
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
|
-
/**
|
|
138
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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
|
|
458
|
-
const all = [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
|
|
574
|
+
let lineage
|
|
539
575
|
try {
|
|
540
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
739
|
-
|
|
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:
|
|
789
|
+
descendants: lineage.subagents.length,
|
|
742
790
|
})
|
|
743
791
|
}
|
|
744
792
|
const source = workspaceOfId(id)
|
package/lib/state.js
CHANGED
|
@@ -140,10 +140,31 @@ function headerOf(row, snapshotsById, ctx) {
|
|
|
140
140
|
return snapshotsById.get(row.sessionId)?.header
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
/**
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Classify one session from whatever identifying fields are available.
|
|
145
|
+
*
|
|
146
|
+
* Callers pass a stored header, a list summary, or a merged view, so both
|
|
147
|
+
* field spellings are accepted (`parentSession` on a header, `parentSessionId`
|
|
148
|
+
* on a summary). Keeping this in one place is what makes the listing, the
|
|
149
|
+
* delete scope and the move guard agree on what an ordinary fork is: only
|
|
150
|
+
* delegation (`origin: 'subagent'` or a delegation depth) marks an internal
|
|
151
|
+
* session. A parent alone is not enough — an ordinary fork carries one too,
|
|
152
|
+
* and historical forks still record it.
|
|
153
|
+
*/
|
|
154
|
+
export function classifySession(fields) {
|
|
155
|
+
if (fields?.origin === 'subagent') return 'subagent'
|
|
156
|
+
if (typeof fields?.delegationDepth === 'number' && fields.delegationDepth >= 1) return 'subagent'
|
|
157
|
+
const parent = fields?.parentSession ?? fields?.parentSessionId
|
|
158
|
+
return parent === undefined || parent === null || parent === '' ? 'root' : 'fork'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Classify one row of the listing from its header and summary. */
|
|
162
|
+
function classifyKind(header, summary) {
|
|
163
|
+
return classifySession({
|
|
164
|
+
origin: header?.origin ?? summary?.origin,
|
|
165
|
+
delegationDepth: header?.delegationDepth ?? summary?.delegationDepth,
|
|
166
|
+
parentSession: header?.parentSession ?? summary?.parentSessionId,
|
|
167
|
+
})
|
|
147
168
|
}
|
|
148
169
|
|
|
149
170
|
/**
|
|
@@ -201,6 +222,7 @@ export async function buildState(ctx, config, options = {}) {
|
|
|
201
222
|
for (const id of snapshotById.keys()) knownIds.add(id)
|
|
202
223
|
const rows = []
|
|
203
224
|
const missingTitles = []
|
|
225
|
+
let hiddenSubagents = 0
|
|
204
226
|
|
|
205
227
|
const pushRow = (base) => {
|
|
206
228
|
const id = base.sessionId
|
|
@@ -208,8 +230,11 @@ export async function buildState(ctx, config, options = {}) {
|
|
|
208
230
|
seen.add(id)
|
|
209
231
|
const header = headerOf(base, snapshotById, ctx)
|
|
210
232
|
const summary = base
|
|
211
|
-
const
|
|
212
|
-
if (subagent && !includeSubagents)
|
|
233
|
+
const kind = classifyKind(header, summary)
|
|
234
|
+
if (kind === 'subagent' && !includeSubagents) {
|
|
235
|
+
hiddenSubagents += 1
|
|
236
|
+
return
|
|
237
|
+
}
|
|
213
238
|
|
|
214
239
|
const cwd = summary.cwd ?? header?.cwd ?? ''
|
|
215
240
|
const workspace =
|
|
@@ -234,13 +259,15 @@ export async function buildState(ctx, config, options = {}) {
|
|
|
234
259
|
running,
|
|
235
260
|
blank: summary.blank === true,
|
|
236
261
|
origin: header?.origin ?? summary.origin,
|
|
237
|
-
|
|
262
|
+
kind,
|
|
263
|
+
isSubagent: kind === 'subagent',
|
|
264
|
+
isFork: kind === 'fork',
|
|
265
|
+
parentSessionId: header?.parentSession ?? summary.parentSessionId ?? null,
|
|
238
266
|
agentPreset: header?.agentPreset ?? '',
|
|
239
267
|
createdAt: header?.createdAt ?? summary.updatedAt ?? 0,
|
|
240
268
|
updatedAt: summary.updatedAt ?? header?.createdAt ?? 0,
|
|
241
269
|
sizeBytes: typeof snapshot?.sizeBytes === 'number' ? snapshot.sizeBytes : 0,
|
|
242
270
|
formatStatus: summary.formatStatus ?? snapshot?.formatStatus,
|
|
243
|
-
isSubagent: subagent,
|
|
244
271
|
})
|
|
245
272
|
}
|
|
246
273
|
|
|
@@ -268,6 +295,22 @@ export async function buildState(ctx, config, options = {}) {
|
|
|
268
295
|
if (row.title === '') row.title = titles.get(row.id) ?? ''
|
|
269
296
|
}
|
|
270
297
|
|
|
298
|
+
// A fork points at its parent by id only; resolving the title here lets the
|
|
299
|
+
// list show the relationship without a second round trip. `parentPresent`
|
|
300
|
+
// lets the client tell "untitled parent" apart from "the parent session is
|
|
301
|
+
// gone" (possible after the parent is permanently deleted).
|
|
302
|
+
const titlesById = new Map(rows.map((row) => [row.id, row.title]))
|
|
303
|
+
const forkCountByParent = new Map()
|
|
304
|
+
for (const row of rows) {
|
|
305
|
+
row.parentTitle = row.kind === 'fork' ? (titlesById.get(row.parentSessionId) ?? '') : ''
|
|
306
|
+
row.parentPresent = row.kind === 'fork' && titlesById.has(row.parentSessionId)
|
|
307
|
+
if (row.kind !== 'fork' || row.parentSessionId === null) continue
|
|
308
|
+
forkCountByParent.set(row.parentSessionId, (forkCountByParent.get(row.parentSessionId) ?? 0) + 1)
|
|
309
|
+
}
|
|
310
|
+
for (const row of rows) {
|
|
311
|
+
row.forkCount = forkCountByParent.get(row.id) ?? 0
|
|
312
|
+
}
|
|
313
|
+
|
|
271
314
|
const jsonl =
|
|
272
315
|
typeof ctx.sessionPersistence?.locate === 'function' && typeof ctx.sessionPersistence?.stat === 'function'
|
|
273
316
|
const capabilities = {
|
|
@@ -282,7 +325,7 @@ export async function buildState(ctx, config, options = {}) {
|
|
|
282
325
|
title: capabilities.title,
|
|
283
326
|
archive: !row.archived,
|
|
284
327
|
restore: row.archived,
|
|
285
|
-
move: capabilities.move && !row.live && !row.running &&
|
|
328
|
+
move: capabilities.move && !row.live && !row.running && row.kind === 'root',
|
|
286
329
|
delete: capabilities.delete && row.archived && !row.live,
|
|
287
330
|
open: true,
|
|
288
331
|
}
|
|
@@ -323,6 +366,8 @@ export async function buildState(ctx, config, options = {}) {
|
|
|
323
366
|
active: rows.filter((row) => !row.archived).length,
|
|
324
367
|
archived: rows.filter((row) => row.archived).length,
|
|
325
368
|
subagents: rows.filter((row) => row.isSubagent).length,
|
|
369
|
+
hiddenSubagents,
|
|
370
|
+
forks: rows.filter((row) => row.isFork).length,
|
|
326
371
|
bytes: totalBytes,
|
|
327
372
|
},
|
|
328
373
|
limits: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-mng",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Session management for DeepSeek Harness: one Settings page for every session — whole-session title generation, archive/restore, cross-workspace move, and permanent deletion of archived sessions.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/ZilongYang/dsh-session-mng.git"
|
|
12
12
|
},
|
|
13
|
-
"homepage": "https://
|
|
13
|
+
"homepage": "https://dsh.zlmix.com/session-mng/",
|
|
14
14
|
"bugs": {
|
|
15
15
|
"url": "https://github.com/ZilongYang/dsh-session-mng/issues"
|
|
16
16
|
},
|
|
@@ -71,7 +71,8 @@
|
|
|
71
71
|
"compatibility": {
|
|
72
72
|
"dsh": ">=0.2.0-rc.1 <0.3.0-0",
|
|
73
73
|
"dshReleases": {
|
|
74
|
-
"0.2.1-rc.2": "compatible"
|
|
74
|
+
"0.2.1-rc.2": "compatible",
|
|
75
|
+
"0.2.1-rc.3": "compatible"
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
}
|