@wangxiaocuo/dsh-session-trash 0.5.1 → 0.6.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/README.en.md CHANGED
@@ -10,7 +10,8 @@ Status: feature complete and covered by the regression suite. macOS has been ver
10
10
 
11
11
  ## Features
12
12
 
13
- - Right-click a session in the sidebar and choose "Move to trash". The item appears only for cold sessions with no persisted children and no running turn, job, subagent, or scheduled task.
13
+ - Right-click a session in the sidebar and choose "Move to trash". It is available for cold sessions with no running turn, job, subagent, or scheduled task; hidden subagent children move with their parent and are restored together.
14
+ - For sessions that are still open, still running work, or own visible child sessions, the menu keeps a labelled explanatory "Move to trash" item that states why the move is unavailable instead of hiding the entry entirely.
14
15
  - After the move, the shared DSH Toast offers one action: Undo. Undo tries a normal restore first and falls back to an orphan restore when the original workspace is gone.
15
16
  - The Trash page in Settings shows title, Session ID, original workspace, trashed date, remaining days, size, and status. The list supports search, filters, and paging at 50 rows per page.
16
17
  - Row actions include restore, restore as orphan, permanent deletion, removal from the index, copying the Session ID, and revealing the original workspace in Finder or Explorer.
@@ -102,6 +103,8 @@ Defaults apply when `config` is omitted. Invalid values reject activation instea
102
103
 
103
104
  Right-click a cold session and choose "Move to trash". After the Toast disappears, open Settings, then Trash, and restore the session. If the original workspace directory is gone, the page asks for confirmation and restores the session as an orphan under "Ungrouped". To empty the trash at once, use "Delete all permanently" in the toolbar and confirm; entries that have not expired are deleted too.
104
105
 
106
+ Trash rows that own hidden subagent children show a badge with the child count; restore and permanent deletion always cover the whole tree.
107
+
105
108
  The current DSH version keeps a live agent for a session after it is opened, until the Host process exits. An idle session is still live, so the menu item stays hidden. Restart DSH before trashing it, or archive it. A second DSH instance does not detect a session that is live in another process, so do not use one to bypass this check.
106
109
 
107
110
  ## HTTP API
@@ -111,8 +114,8 @@ All routes live under `/plugins/dsh-session-trash/api/` and are registered as ex
111
114
  | Method | Path | Description |
112
115
  |---|---|---|
113
116
  | `GET` | `/state` | Config, index state, entries, stats, and cleanup diagnostics; may schedule a throttled page-open cleanup |
114
- | `POST` | `/preflight` | `{ sessionId }` returns `{ eligible: true }` or `{ eligible: false, code, message }` |
115
- | `POST` | `/trash` | `{ sessionId }`; moves the session directory |
117
+ | `POST` | `/preflight` | `{ sessionId }` returns `{ eligible: true, descendants }` or `{ eligible: false, code, message, details? }`; `descendants` is the number of hidden children that move along |
118
+ | `POST` | `/trash` | `{ sessionId }`; moves the whole session tree (parent plus hidden subagent children) and returns `{ ok, entry }` |
116
119
  | `POST` | `/restore` | `{ sessionId, mode: 'normal' \| 'orphan' }` |
117
120
  | `POST` | `/purge` | `{ sessionId }`; permanently deletes the session log |
118
121
  | `POST` | `/purge-all` | No body; permanently deletes every entry in the trash, dropping `missing` index records only; returns `{ ok, purged, forgotten, failed }` |
@@ -129,6 +132,8 @@ Restore and maintenance refusals include `TRASH_ENTRY_NOT_FOUND`, `TRASH_STATE_I
129
132
 
130
133
  `/state.stats.cleanup` reports the last cleanup attempt, the next sweep, a pending retry, consecutive failures, and the last run summary.
131
134
 
135
+ `/state` entries gain `subagentCount` (hidden children moved along) and `members` (parent first, each with `sessionId`, `origin`, and `sizeBytes`); `sizeBytes`/`fileCount` cover the whole tree. The `entry` returned by `/trash` uses the same shape.
136
+
132
137
  ## Storage and recovery
133
138
 
134
139
  ```text
@@ -139,7 +144,9 @@ Restore and maintenance refusals include `TRASH_ENTRY_NOT_FOUND`, `TRASH_STATE_I
139
144
  objects/
140
145
  <trashId>/
141
146
  meta.json # object metadata
142
- session/ # the moved session directory
147
+ session/ # the moved parent session directory
148
+ children/
149
+ <sessionId>/ # hidden subagent child directory moved along
143
150
  ```
144
151
 
145
152
  The index is committed atomically with `tmp`, `fsync`, and `rename`, and the previous commit is kept as `.bak`. On activation and before every sweep, the plugin settles unfinished `trashing`, `restoring`, and `purging` entries. It completes what can be completed, rolls back what can be rolled back, marks uncertain cases as `missing`, and never deletes a session log during that recovery pass.
@@ -148,10 +155,13 @@ A same-filesystem move uses `rename`. A cross-filesystem move copies into a temp
148
155
 
149
156
  On POSIX filesystems, the trash root uses `0700` permissions and the index and metadata files use `0600`. A lock older than 10 minutes is treated as stale.
150
157
 
158
+ The index schema is v2; a v1 index is upgraded automatically on startup and preserved as `.bak`. Older plugin releases cannot read v2 and enter protection mode, so empty the trash before downgrading.
159
+
151
160
  ## Limitations
152
161
 
153
- - The current DSH version keeps an opened session live until the Host process exits. The "Move to trash" item stays hidden for such a session. Restart DSH before trashing it.
154
- - Parent sessions with persisted children and subagent sessions cannot be trashed on their own. Archive the whole tree instead.
162
+ - The current DSH version keeps an opened session live until the Host process exits. The context menu then shows the explanatory item instead of an actionable move; restart DSH before trashing it.
163
+ - Parent sessions with visible (non-subagent) children cannot be trashed on their own; archive the whole tree instead. Hidden subagent children move with their parent.
164
+ - A trashed tree is restored or deleted as a whole; there is no partial restore. If any member directory is missing, the entry is marked `missing` and can only be purged or forgotten.
155
165
  - Orphan restore never recreates the original workspace directory and never reattaches the session to a workspace.
156
166
  - Archive and pin state survive trash and restore. The Trash page does not manage either set.
157
167
  - Permanent deletion removes the session log only. Attachments, images, projection-cache rows, and search-index rows are not deleted.
@@ -165,19 +175,14 @@ On POSIX filesystems, the trash root uses `0700` permissions and the index and m
165
175
 
166
176
  ```bash
167
177
  pnpm install
168
- pnpm test
169
- npm run test:e2e
178
+ pnpm test # unit tests
179
+ pnpm test:smoke # Host smoke test (real Cordis, plain Node)
180
+ npm run test:e2e # real dsh web end-to-end (needs the desktop app)
170
181
  ```
171
182
 
172
- `npm run test:e2e` boots a real `dsh web` Host in a temporary `DSH_HOME` and runs the full flow. It uses `/Applications/DeepSeek Harness.app` by default; set `DSH_ELECTRON` to another Electron executable. Set `DSH_E2E_KEEP=1` to keep the temporary home and logs.
173
-
174
- The Host smoke test needs a separate temporary `DSH_HOME`:
183
+ The Host smoke test wires the plugin into a real Cordis context through the `@deepseek-ai/cordis` devDependency, creates its own temporary `DSH_HOME`, and removes it afterwards; it does not need the desktop app. Set `DSH_SMOKE_KEEP=1` to keep the temporary home for debugging. CI (GitHub Actions) runs the unit tests, the smoke test, and a package-contents check; the end-to-end suite needs a local DeepSeek Harness desktop install and is run manually.
175
184
 
176
- ```bash
177
- DSH_HOME=$(mktemp -d) ELECTRON_RUN_AS_NODE=1 \
178
- "/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness" \
179
- test/smoke-host.mjs
180
- ```
185
+ `npm run test:e2e` boots a real `dsh web` Host in a temporary `DSH_HOME` and runs the full flow. It uses `/Applications/DeepSeek Harness.app` by default; set `DSH_ELECTRON` to another Electron executable. Set `DSH_E2E_KEEP=1` to keep the temporary home and logs.
181
186
 
182
187
  Project layout:
183
188
 
package/README.md CHANGED
@@ -6,13 +6,15 @@
6
6
 
7
7
  DeepSeek Harness 的会话回收站插件。冷会话可以从侧边栏移出,日志保留在本地;保留期内可以恢复,到期后自动删除日志。面向 DSH 0.2.2-rc.1 的 Desktop 和 Web,Host 和 Client 在同一个包里,不需要构建步骤。
8
8
 
9
- 当前状态:功能已实现,回归测试通过。macOS 已在真实 DSH Host 上验证;Windows 和 Linux 的路径、锁、重试规则有单测覆盖,尚未在真实主机上运行。
9
+ 当前状态:功能已实现,回归测试与真实 Host 端到端测试通过。macOS 已在真实 DSH Host 上验证;Windows 和 Linux 的路径、锁、重试规则有单测覆盖,尚未在真实主机上运行。
10
10
 
11
11
  ## 功能
12
12
 
13
- - 侧边栏右键会话,选择「移入回收站」。只有冷会话、没有持久化子会话、也没有运行中的 turn、job、subagent 或定时任务时,菜单项才会出现。
13
+ - 侧边栏右键会话,选择「移入回收站」。只有冷会话、没有运行中的 turn、job、subagent 或定时任务时才能移入;会话跑过的隐藏 subagent 子会话会随父会话一起移入、一起恢复。
14
+ - 对仍打开的会话、正在运行的会话或含可见子会话的会话,菜单保留一个带原因的「移入回收站」说明项:点开解释为什么暂时不能移入,而不是把菜单项直接隐藏。
14
15
  - 移入后显示 DSH 共享 Toast,只有「撤销」一个动作。撤销先尝试正常恢复,原工作区不存在时改为孤儿恢复。
15
16
  - 设置页「回收站」显示标题、Session ID、原工作区、移入时间、剩余天数、大小和状态。列表支持搜索、筛选,每页 50 条。
17
+ - 含子会话的条目显示「含 N 个子会话」徽章;恢复和永久删除按整棵树处理,不做部分恢复。
16
18
  - 行操作包括恢复、恢复为孤儿会话、永久删除、从索引移除、复制 Session ID,以及在 Finder 或资源管理器中显示原工作区。
17
19
  - 工具栏「永久删除全部」在二次确认后清空回收站:删除所有已移入的会话日志,并把文件缺失的记录移出索引;附件、图片和搜索索引等衍生物不删除。
18
20
  - 默认保留 30 天,每 6 小时扫描一次;插件激活后和打开回收站页面时也会检查。清理失败按指数退避重试,失败信息显示在页面上。
@@ -102,7 +104,7 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
102
104
 
103
105
  右键冷会话选择「移入回收站」。Toast 消失后,仍可从设置页「回收站」恢复;原工作区目录已不存在时,页面会先确认,确认后会话回到「未分组」。需要清空时点工具栏「永久删除全部」并确认,未过期的条目也会删除。
104
106
 
105
- 当前 DSH 版本会把打开过的会话保留为 live agent,直到该 Host 进程结束。会话即使空闲也算 live,菜单项不会出现;需要回收时先重启 DSH,或直接归档。另一个 DSH 实例不会识别这种 live 状态,不要用它绕过限制。
107
+ 当前 DSH 版本会把打开过的会话保留为 live agent,直到该 Host 进程结束。会话即使空闲也算 live,此时右键菜单显示的是带原因的说明项、不能移入;需要回收时先重启 DSH,或直接归档。另一个 DSH 实例不会识别这种 live 状态,不要用它绕过限制。
106
108
 
107
109
  ## HTTP API
108
110
 
@@ -111,8 +113,8 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
111
113
  | 方法 | 路径 | 说明 |
112
114
  |---|---|---|
113
115
  | `GET` | `/state` | 配置、索引状态、条目、统计和清理诊断;打开页面时可能触发一次节流清理 |
114
- | `POST` | `/preflight` | `{ sessionId }` 返回 `{ eligible: true }` 或 `{ eligible: false, code, message }` |
115
- | `POST` | `/trash` | `{ sessionId }`,移动会话目录 |
116
+ | `POST` | `/preflight` | `{ sessionId }` 返回 `{ eligible: true, descendants }` 或 `{ eligible: false, code, message, details? }`;`descendants` 是一并移入的隐藏子会话数 |
117
+ | `POST` | `/trash` | `{ sessionId }`,移动整棵会话树(父会话 + 隐藏 subagent 子会话),返回 `{ ok, entry }` |
116
118
  | `POST` | `/restore` | `{ sessionId, mode: 'normal' \| 'orphan' }` |
117
119
  | `POST` | `/purge` | `{ sessionId }`,永久删除会话日志 |
118
120
  | `POST` | `/purge-all` | 无请求体,永久删除回收站中的全部条目;`missing` 记录仅移除索引;返回 `{ ok, purged, forgotten, failed }` |
@@ -129,6 +131,8 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
129
131
 
130
132
  `/state` 的 `stats.cleanup` 记录上次清理时间、下次清理时间、重试时间、连续失败次数和上次运行摘要。
131
133
 
134
+ `/state` 的条目新增 `subagentCount`(一并移入的隐藏子会话数)和 `members`(父会话在前,含 `sessionId`、`origin`、`sizeBytes`);`sizeBytes`/`fileCount` 是整棵树的总量。`/trash` 返回的 `entry` 使用同一形状。
135
+
132
136
  ## 数据与恢复
133
137
 
134
138
  ```text
@@ -139,7 +143,9 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
139
143
  objects/
140
144
  <trashId>/
141
145
  meta.json # 对象元数据
142
- session/ # 被移动的会话目录
146
+ session/ # 被移动的父会话目录
147
+ children/
148
+ <sessionId>/ # 一并移入的隐藏 subagent 子会话目录
143
149
  ```
144
150
 
145
151
  索引通过 `tmp`、`fsync`、`rename` 原子提交,旧版本保留为 `.bak`。启动时和每次清理前,插件会处理未完成的 `trashing`、`restoring`、`purging` 状态:能完成的完成,能回滚的回滚,无法判断的标记为 `missing`,不会删除会话日志。
@@ -148,10 +154,13 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
148
154
 
149
155
  在 POSIX 文件系统上,回收站目录权限为 `0700`,索引和元数据为 `0600`。锁文件超过 10 分钟未更新时视为陈旧。
150
156
 
157
+ 索引 schema 为 v2,v1 索引会在启动时自动升级并保留为 `.bak`。旧版本插件读不懂 v2 索引,会进入保护模式;降级前请先清空回收站。
158
+
151
159
  ## 限制
152
160
 
153
161
  - 当前 DSH 版本中,打开过的会话会保持 live 直到 Host 进程结束,因此不会显示「移入回收站」。需要回收时重启 DSH。
154
- - 带子会话的父会话和 subagent 会话不能单独回收,只能归档整棵树。
162
+ - 带可见子会话(非 subagent)的父会话不能回收,只能归档整棵树;subagent 的隐藏子会话随父会话一起移入。
163
+ - 整棵树按整体恢复或删除,不做部分恢复:任一成员目录缺失时条目会标记为 `missing`,只能永久删除或从索引移除。
155
164
  - 孤儿恢复不会重建原工作区目录,也不会重新挂到工作区。
156
165
  - 归档和置顶状态会跨回收、恢复保留,回收站页面不管理这两个集合。
157
166
  - 永久删除只删除会话日志。附件、图片、投影缓存和搜索索引不会被删除。
@@ -165,19 +174,14 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
165
174
 
166
175
  ```bash
167
176
  pnpm install
168
- pnpm test
169
- npm run test:e2e
177
+ pnpm test # 单元测试
178
+ pnpm test:smoke # Host 冒烟测试(真实 Cordis,普通 Node 即可)
179
+ npm run test:e2e # 真实 dsh web 端到端(需要本机桌面版)
170
180
  ```
171
181
 
172
- `npm run test:e2e` 会启动真实 `dsh web` Host,在临时 `DSH_HOME` 中跑完整流程。默认使用 `/Applications/DeepSeek Harness.app`,也可以用 `DSH_ELECTRON` 指向其他 Electron 可执行文件。设置 `DSH_E2E_KEEP=1` 可以保留临时目录和日志。
173
-
174
- Host 冒烟测试需要单独的临时 `DSH_HOME`:
182
+ Host 冒烟测试通过 `@deepseek-ai/cordis` 这个 devDependency 装配真实插件上下文,自己创建并清理临时 `DSH_HOME`,不需要桌面版;设置 `DSH_SMOKE_KEEP=1` 可以保留临时目录用于排查。CI(GitHub Actions)会跑单元测试、冒烟测试和打包内容检查;e2e 依赖本机的 DeepSeek Harness 桌面版,需要手动执行。
175
183
 
176
- ```bash
177
- DSH_HOME=$(mktemp -d) ELECTRON_RUN_AS_NODE=1 \
178
- "/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness" \
179
- test/smoke-host.mjs
180
- ```
184
+ `npm run test:e2e` 会启动真实 `dsh web` Host,在临时 `DSH_HOME` 中跑完整流程。默认使用 `/Applications/DeepSeek Harness.app`,也可以用 `DSH_ELECTRON` 指向其他 Electron 可执行文件。设置 `DSH_E2E_KEEP=1` 可以保留临时目录和日志。
181
185
 
182
186
  项目结构:
183
187
 
package/client.js CHANGED
@@ -24,6 +24,18 @@ window.__ModuleLoader__.load({
24
24
  'workspace-missing': 'filterWorkspaceMissing',
25
25
  expiring: 'filterExpiring',
26
26
  }
27
+ /**
28
+ * Preflight refusals the user can act on themselves. Those keep a visible
29
+ * menu entry that explains the refusal instead of hiding it.
30
+ */
31
+ const MOVE_BLOCKED_KEYS = {
32
+ SESSION_LIVE: { label: 'moveBlockedLive', toast: 'blockedLiveToast' },
33
+ SESSION_ACTIVE: { label: 'moveBlockedActive', toast: 'blockedActiveToast' },
34
+ SESSION_HAS_DESCENDANTS: {
35
+ label: 'moveBlockedDescendants',
36
+ toast: 'blockedDescendantsToast',
37
+ },
38
+ }
27
39
 
28
40
  /** Open trash views subscribe here so one successful mutation refreshes them all. */
29
41
  const revisionListeners = new Set()
@@ -110,6 +122,7 @@ window.__ModuleLoader__.load({
110
122
  badgeMigration: '需要迁移',
111
123
  badgeExpiring: '即将到期',
112
124
  badgeExpired: '已过期',
125
+ badgeSubagents: '含 {count} 个子会话',
113
126
  stateTrashing: '正在移入…',
114
127
  stateRestoring: '正在恢复…',
115
128
  statePurging: '正在删除…',
@@ -153,9 +166,16 @@ window.__ModuleLoader__.load({
153
166
  lastErrorLabel: '上次错误:{message}',
154
167
  footnote: '回收站目录:{path}',
155
168
  move: '移入回收站',
169
+ moveBlockedLive: '移入回收站(会话已打开)',
170
+ moveBlockedActive: '移入回收站(有任务在运行)',
171
+ moveBlockedDescendants: '移入回收站(含可见子会话)',
156
172
  undo: '撤销',
157
173
  undoFailed: '撤销失败',
158
174
  trashSuccess: '已将「{title}」移入回收站',
175
+ trashSuccessTree: '「{title}」及其 {count} 个子会话已移入回收站',
176
+ blockedLiveToast: '该会话已在本机打开。DSH 目前不会释放已打开的会话,请重启 DSH 后再移入回收站。',
177
+ blockedActiveToast: '该会话还有正在运行的任务或子代理,等它停下来后再移入回收站。',
178
+ blockedDescendantsToast: '该会话包含可见的子会话,只能使用 DSH 自带的「归档」整棵会话树。',
159
179
  restoreSuccess: '已恢复「{title}」',
160
180
  restoreOrphanSuccess: '已恢复为孤儿会话「{title}」',
161
181
  purgeSuccess: '已永久删除「{title}」',
@@ -239,6 +259,7 @@ window.__ModuleLoader__.load({
239
259
  badgeMigration: 'Migration required',
240
260
  badgeExpiring: 'Expiring soon',
241
261
  badgeExpired: 'Expired',
262
+ badgeSubagents: '{count} sub-sessions',
242
263
  stateTrashing: 'Moving…',
243
264
  stateRestoring: 'Restoring…',
244
265
  statePurging: 'Deleting…',
@@ -282,9 +303,16 @@ window.__ModuleLoader__.load({
282
303
  lastErrorLabel: 'Last error: {message}',
283
304
  footnote: 'Trash directory: {path}',
284
305
  move: 'Move to trash',
306
+ moveBlockedLive: 'Move to trash (session is open)',
307
+ moveBlockedActive: 'Move to trash (work is running)',
308
+ moveBlockedDescendants: 'Move to trash (has visible sub-sessions)',
285
309
  undo: 'Undo',
286
310
  undoFailed: 'Could not undo',
287
311
  trashSuccess: 'Moved “{title}” to the trash',
312
+ trashSuccessTree: '“{title}” and its {count} sub-sessions were moved to the trash',
313
+ blockedLiveToast: 'This session is open in DSH. DSH does not release opened sessions yet; restart DSH, then move it to the trash.',
314
+ blockedActiveToast: 'This session still has running work; wait for it to stop, then move it to the trash.',
315
+ blockedDescendantsToast: 'This session has visible child sessions; use the built-in Archive action for the whole tree instead.',
288
316
  restoreSuccess: 'Restored “{title}”',
289
317
  restoreOrphanSuccess: 'Restored “{title}” as an orphan session',
290
318
  purgeSuccess: 'Permanently deleted “{title}”',
@@ -490,7 +518,7 @@ window.__ModuleLoader__.load({
490
518
  /**
491
519
  * @param {string} sessionId session whose eligibility to ask for.
492
520
  * @param {AbortSignal} [outerSignal] cancellation from the menu occurrence.
493
- * @returns {Promise<{ eligible: boolean, code?: string } | null>} the verdict, or null when unknown.
521
+ * @returns {Promise<{ eligible: boolean, code?: string, message?: string, details?: unknown } | null>} the verdict, or null when unknown.
494
522
  */
495
523
  async function preflight(sessionId, outerSignal) {
496
524
  const controller = new AbortController()
@@ -1526,6 +1554,14 @@ window.__ModuleLoader__.load({
1526
1554
  if (entry.formatStatus === 'migration-required') {
1527
1555
  badges.push({ key: 'migration', label: t('badgeMigration'), tone: 'warn' })
1528
1556
  }
1557
+ const subagentCount = entry.subagentCount ?? 0
1558
+ if (subagentCount > 0) {
1559
+ badges.push({
1560
+ key: 'subagents',
1561
+ label: format(t('badgeSubagents'), { count: subagentCount }),
1562
+ tone: 'info',
1563
+ })
1564
+ }
1529
1565
  const retention = remaining(entry, locale, now)
1530
1566
  if (retention.days !== null && retention.days <= 3) {
1531
1567
  badges.push({
@@ -2019,14 +2055,23 @@ window.__ModuleLoader__.load({
2019
2055
  return
2020
2056
  }
2021
2057
  bumpRevision()
2022
- const responseTitle = payload !== null && payload !== undefined && payload.entry !== undefined ? payload.entry.title : undefined
2058
+ const responseEntry = payload !== null && payload !== undefined ? payload.entry : undefined
2059
+ const responseTitle = responseEntry !== null && responseEntry !== undefined ? responseEntry.title : undefined
2023
2060
  const title =
2024
2061
  typeof responseTitle === 'string' && responseTitle !== ''
2025
2062
  ? responseTitle
2026
2063
  : typeof fallbackTitle === 'string' && fallbackTitle !== ''
2027
2064
  ? fallbackTitle
2028
2065
  : sessionId
2029
- ctx.contextMenus.notify(format(t('trashSuccess'), { title }), true, {
2066
+ const subagentCount =
2067
+ responseEntry !== null && responseEntry !== undefined && typeof responseEntry.subagentCount === 'number'
2068
+ ? responseEntry.subagentCount
2069
+ : 0
2070
+ const message =
2071
+ subagentCount > 0
2072
+ ? format(t('trashSuccessTree'), { title, count: subagentCount })
2073
+ : format(t('trashSuccess'), { title })
2074
+ ctx.contextMenus.notify(message, true, {
2030
2075
  label: t('undo'),
2031
2076
  run: () => undoTrash(sessionId, title),
2032
2077
  failureLabel: t('undoFailed'),
@@ -2153,7 +2198,20 @@ window.__ModuleLoader__.load({
2153
2198
  }
2154
2199
  }
2155
2200
  const verdict = await preflight(sessionId, signal)
2156
- if (verdict === null || verdict.eligible !== true) return []
2201
+ if (verdict === null) return []
2202
+ if (verdict.eligible !== true) {
2203
+ const blocked = typeof verdict.code === 'string' ? MOVE_BLOCKED_KEYS[verdict.code] : undefined
2204
+ if (blocked === undefined) return []
2205
+ return [
2206
+ {
2207
+ id: 'move-refused',
2208
+ label: t(blocked.label),
2209
+ icon: { key: MENU_ID },
2210
+ separatorBefore: true,
2211
+ run: () => ctx.contextMenus.notify(t(blocked.toast), false),
2212
+ },
2213
+ ]
2214
+ }
2157
2215
  return [
2158
2216
  {
2159
2217
  id: 'move',
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Small concurrency helpers shared by the trash services.
3
+ *
4
+ * @module dsh-session-trash/concurrency
5
+ */
6
+
7
+ /**
8
+ * Run `worker` over every item with a bounded number of in-flight operations.
9
+ * Results keep the input order; the first rejection stops the remaining work and
10
+ * rejects the returned promise.
11
+ *
12
+ * @param {readonly T[]} items input items.
13
+ * @param {number} limit maximum in-flight operations.
14
+ * @param {(item: T, index: number) => Promise<R>} worker per-item operation.
15
+ * @returns {Promise<R[]>} results in input order.
16
+ * @template T, R
17
+ */
18
+ export async function mapLimit(items, limit, worker) {
19
+ const results = new Array(items.length)
20
+ let next = 0
21
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
22
+ while (next < items.length) {
23
+ const index = next
24
+ next += 1
25
+ results[index] = await worker(items[index], index)
26
+ }
27
+ })
28
+ await Promise.all(runners)
29
+ return results
30
+ }
@@ -6,7 +6,9 @@ import { isSafeDirectoryPath } from './fsops.js'
6
6
  export const PREFLIGHT_TTL_MS = 2000
7
7
 
8
8
  /**
9
- * @typedef {{ eligible: true } | { eligible: false, code: string, message: string, details?: object }} PreflightResult
9
+ * @typedef {{ sessionId: string, header: object }} DescendantRef
10
+ * @typedef {{ sessionId: string, header: object, location: { path: string }, formatStatus?: string }} PlannedMember
11
+ * @typedef {{ eligible: true, members: PlannedMember[] } | { eligible: false, code: string, message: string, details?: object }} PreflightResult
10
12
  */
11
13
 
12
14
  /**
@@ -20,32 +22,33 @@ function refuse(code, message, details) {
20
22
  }
21
23
 
22
24
  /**
23
- * Collect the ids of every stored session whose lineage descends from `sessionId`.
25
+ * Collect every stored session whose lineage descends from `sessionId`.
24
26
  *
25
27
  * @param {import('@deepseek-ai/dsh-session-persistence').SessionPersistence} persistence persistence service.
26
28
  * @param {string} sessionId root session.
27
- * @returns {Promise<string[]>} descendant ids.
29
+ * @returns {Promise<DescendantRef[]>} descendant identity and stored header.
28
30
  */
29
31
  async function descendantsFromListing(persistence, sessionId) {
30
32
  const snapshots = await persistence.list()
31
33
  const childrenByParent = new Map()
32
34
  for (const snapshot of snapshots) {
33
- const parent = snapshot?.header?.parentSession
34
- if (typeof parent !== 'string') continue
35
+ const header = snapshot?.header
36
+ const parent = header?.parentSession
37
+ if (typeof parent !== 'string' || typeof header?.id !== 'string') continue
35
38
  const bucket = childrenByParent.get(parent)
36
- if (bucket === undefined) childrenByParent.set(parent, [snapshot.header.id])
37
- else bucket.push(snapshot.header.id)
39
+ if (bucket === undefined) childrenByParent.set(parent, [header])
40
+ else bucket.push(header)
38
41
  }
39
42
  const found = []
40
43
  const queue = [sessionId]
41
44
  const visited = new Set([sessionId])
42
45
  while (queue.length > 0) {
43
46
  const current = queue.shift()
44
- for (const child of childrenByParent.get(current) ?? []) {
45
- if (visited.has(child)) continue
46
- visited.add(child)
47
- found.push(child)
48
- queue.push(child)
47
+ for (const header of childrenByParent.get(current) ?? []) {
48
+ if (visited.has(header.id)) continue
49
+ visited.add(header.id)
50
+ found.push({ sessionId: header.id, header })
51
+ queue.push(header.id)
49
52
  }
50
53
  }
51
54
  return found
@@ -57,24 +60,26 @@ async function descendantsFromListing(persistence, sessionId) {
57
60
  * root session owns a lineage the trash must not split.
58
61
  *
59
62
  * @param {readonly object[]} nodes descendant nodes from the trace.
60
- * @returns {string[]} every descendant session id.
63
+ * @returns {DescendantRef[]} every descendant session identity and header.
61
64
  */
62
65
  function collectTraceDescendants(nodes) {
63
- const ids = []
66
+ const found = []
64
67
  const visit = (list) => {
65
68
  for (const node of list) {
66
- const id = node?.session?.header?.id ?? node?.session?.id
67
- if (typeof id === 'string' && id !== '') ids.push(id)
69
+ const header = node?.session?.header
70
+ const id = header?.id ?? node?.session?.id
71
+ if (typeof id === 'string' && id !== '') found.push({ sessionId: id, header: header ?? {} })
68
72
  if (Array.isArray(node?.descendants)) visit(node.descendants)
69
73
  }
70
74
  }
71
75
  visit(Array.isArray(nodes) ? nodes : [])
72
- return ids
76
+ return found
73
77
  }
74
78
 
75
79
  /**
76
- * Decide whether one session may move into the trash. The same predicate backs
77
- * both the menu preflight and (from Phase 2) the write path.
80
+ * Decide whether one session may move into the trash. A session that owns only
81
+ * hidden `origin: 'subagent'` descendants may move together with them; any
82
+ * other descendant keeps the historical refusal.
78
83
  *
79
84
  * @param {{ ctx: import('@deepseek-ai/cordis').Context, store: import('./store.js').TrashStore, logger: object }} deps host services.
80
85
  * @returns {{ evaluate: (sessionId: string) => Promise<PreflightResult>, invalidate: (sessionId?: string) => void }} the cached evaluator.
@@ -83,6 +88,82 @@ export function createEligibility({ ctx, store, logger }) {
83
88
  /** @type {Map<string, { expires: number, value: PreflightResult }>} */
84
89
  const cache = new Map()
85
90
 
91
+ /**
92
+ * @param {string} sessionId session to inspect.
93
+ * @returns {boolean} whether the session is live in this host process.
94
+ */
95
+ function isLive(sessionId) {
96
+ const liveSession = typeof ctx.sessions?.get === 'function' ? ctx.sessions.get(sessionId) : undefined
97
+ const liveAgent = typeof ctx.agents?.get === 'function' ? ctx.agents.get(sessionId) : undefined
98
+ return liveSession !== undefined || liveAgent !== undefined
99
+ }
100
+
101
+ /**
102
+ * @param {string} sessionId session to inspect.
103
+ * @returns {Promise<DescendantRef[] | { error: true }>} the complete descendant list, or a refusal marker.
104
+ */
105
+ async function collectDescendants(sessionId) {
106
+ const sessionQuery = ctx.get('sessionQuery')
107
+ if (sessionQuery !== undefined && sessionQuery !== null && typeof sessionQuery.traceSession === 'function') {
108
+ try {
109
+ const trace = await sessionQuery.traceSession(sessionId)
110
+ return collectTraceDescendants(trace?.descendants ?? [])
111
+ } catch (error) {
112
+ logger.warn(`preflight lineage failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`)
113
+ return { error: true }
114
+ }
115
+ }
116
+ try {
117
+ return await descendantsFromListing(ctx.sessionPersistence, sessionId)
118
+ } catch (error) {
119
+ logger.warn(`preflight lineage listing failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`)
120
+ return { error: true }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * @param {string} sessionId descendant session to validate.
126
+ * @returns {Promise<object>} either `{ member }` with the planned member or `{ refusal }`.
127
+ */
128
+ async function planDescendant(sessionId) {
129
+ let snapshot
130
+ try {
131
+ snapshot = await ctx.sessionPersistence.stat(sessionId)
132
+ } catch (error) {
133
+ logger.warn(`preflight stat failed for child session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`)
134
+ return { refusal: refuse('IO_ERROR', 'A child session in this lineage could not be read.', { sessionId }) }
135
+ }
136
+ if (snapshot === undefined || snapshot === null) {
137
+ return {
138
+ refusal: refuse('SESSION_NOT_MATERIALIZED', 'A child session has no stored log yet.', { sessionId }),
139
+ }
140
+ }
141
+ const location = snapshot.location
142
+ if (location === undefined || location === null || typeof location.path !== 'string') {
143
+ return {
144
+ refusal: refuse('SESSION_NOT_MATERIALIZED', 'A child session has no stored log yet.', { sessionId }),
145
+ }
146
+ }
147
+ if (location.kind !== 'jsonl') {
148
+ return {
149
+ refusal: refuse('STORAGE_UNSUPPORTED', `A child session uses the "${String(location.kind)}" storage backend, which is not supported.`, {
150
+ sessionId,
151
+ }),
152
+ }
153
+ }
154
+ if (!path.isAbsolute(location.path)) {
155
+ return { refusal: refuse('STORAGE_UNSUPPORTED', 'A child session log path is not absolute.', { sessionId }) }
156
+ }
157
+ return {
158
+ member: {
159
+ sessionId,
160
+ header: snapshot.header,
161
+ location: { path: location.path },
162
+ formatStatus: snapshot.formatStatus,
163
+ },
164
+ }
165
+ }
166
+
86
167
  /**
87
168
  * @param {string} sessionId session to inspect.
88
169
  * @returns {Promise<PreflightResult>} the uncached verdict.
@@ -125,34 +206,36 @@ export function createEligibility({ ctx, store, logger }) {
125
206
  return refuse('SESSION_SUBAGENT', 'Subagent sessions cannot be moved to the trash on their own.')
126
207
  }
127
208
 
128
- let descendants
129
- const sessionQuery = ctx.get('sessionQuery')
130
- if (sessionQuery !== undefined && typeof sessionQuery.traceSession === 'function') {
131
- try {
132
- const trace = await sessionQuery.traceSession(sessionId)
133
- descendants = collectTraceDescendants(trace?.descendants ?? [])
134
- } catch (error) {
135
- logger.warn(`preflight lineage failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`)
136
- return refuse('SESSION_LINEAGE_UNRESOLVED', 'The session lineage could not be resolved, so the session is left untouched.')
137
- }
138
- } else {
139
- try {
140
- descendants = await descendantsFromListing(ctx.sessionPersistence, sessionId)
141
- } catch (error) {
142
- logger.warn(`preflight lineage listing failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`)
143
- return refuse('SESSION_LINEAGE_UNRESOLVED', 'The session lineage could not be resolved, so the session is left untouched.')
144
- }
209
+ const descendants = await collectDescendants(sessionId)
210
+ if (!Array.isArray(descendants)) {
211
+ return refuse('SESSION_LINEAGE_UNRESOLVED', 'The session lineage could not be resolved, so the session is left untouched.')
145
212
  }
146
- const childCount = descendants.filter((id) => typeof id === 'string' && id !== '').length
147
- if (childCount > 0) {
213
+ const visible = descendants.filter((child) => child.header?.origin !== 'subagent')
214
+ if (visible.length > 0) {
148
215
  return refuse('SESSION_HAS_DESCENDANTS', 'This session still has child sessions; archive the tree instead.', {
149
- descendants: childCount,
216
+ descendants: visible.length,
217
+ sessions: visible.map((child) => child.sessionId),
150
218
  })
151
219
  }
152
220
 
153
- const liveSession = typeof ctx.sessions?.get === 'function' ? ctx.sessions.get(sessionId) : undefined
154
- const liveAgent = typeof ctx.agents?.get === 'function' ? ctx.agents.get(sessionId) : undefined
155
- if (liveSession !== undefined || liveAgent !== undefined) {
221
+ const planned = []
222
+ for (const child of descendants) {
223
+ const plannedChild = await planDescendant(child.sessionId)
224
+ if (plannedChild.refusal !== undefined) return plannedChild.refusal
225
+ planned.push(plannedChild.member)
226
+ }
227
+
228
+ const members = [
229
+ {
230
+ sessionId,
231
+ header: snapshot.header,
232
+ location: { path: location.path },
233
+ formatStatus: snapshot.formatStatus,
234
+ },
235
+ ...planned,
236
+ ]
237
+
238
+ if (isLive(sessionId)) {
156
239
  return refuse('SESSION_LIVE', 'This session is open right now. Close it before moving it to the trash.')
157
240
  }
158
241
 
@@ -169,19 +252,34 @@ export function createEligibility({ ctx, store, logger }) {
169
252
  })
170
253
  }
171
254
 
172
- const sessionDir = path.dirname(location.path)
173
- let stats
174
- try {
175
- stats = await fs.lstat(sessionDir)
176
- } catch (error) {
177
- logger.warn(`preflight cannot stat ${sessionDir}: ${error instanceof Error ? error.message : String(error)}`)
178
- return refuse('IO_ERROR', 'The session directory could not be read.')
255
+ // Running descendants are already reported by the subagent activity
256
+ // provider; a live-but-idle child is the remaining unsafe case.
257
+ const liveChild = members.slice(1).find((member) => isLive(member.sessionId))
258
+ if (liveChild !== undefined) {
259
+ return refuse('SESSION_LIVE', 'A child session is still live in this host.', {
260
+ sessionId: liveChild.sessionId,
261
+ })
179
262
  }
180
- if (!stats.isDirectory() || !isSafeDirectoryPath(sessionDir)) {
181
- return refuse('UNSAFE_SESSION_DIR', 'The session directory is not a plain directory this plugin can move.')
263
+
264
+ for (const member of members) {
265
+ const sessionDir = path.dirname(member.location.path)
266
+ let stats
267
+ try {
268
+ stats = await fs.lstat(sessionDir)
269
+ } catch (error) {
270
+ logger.warn(`preflight cannot stat ${sessionDir}: ${error instanceof Error ? error.message : String(error)}`)
271
+ return refuse('IO_ERROR', 'A session directory could not be read.', {
272
+ ...(member.sessionId === sessionId ? {} : { sessionId: member.sessionId }),
273
+ })
274
+ }
275
+ if (!stats.isDirectory() || !isSafeDirectoryPath(sessionDir)) {
276
+ return refuse('UNSAFE_SESSION_DIR', 'A session directory is not a plain directory this plugin can move.', {
277
+ ...(member.sessionId === sessionId ? {} : { sessionId: member.sessionId }),
278
+ })
279
+ }
182
280
  }
183
281
 
184
- return { eligible: true }
282
+ return { eligible: true, members }
185
283
  }
186
284
 
187
285
  return {