@wangxiaocuo/dsh-session-trash 0.6.4 → 0.6.5

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 CHANGED
@@ -4,6 +4,24 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.5] - 2026-11-11
8
+
9
+ ### Added
10
+
11
+ - 回收站页可以清理不含会话日志的空无主对象(`POST /orphans/forget`);可能含日志的无主对象继续保留,供人工处理。
12
+ - CI 增加 `lib/**` 覆盖率门槛(行 85 / 分支 70 / 函数 84)和 README 的 CI badge。
13
+ - 新增 tag 触发的 release workflow:typecheck、单测、smoke、真实 Host e2e 通过后 `npm publish --provenance` 并创建 GitHub Release;使用 npm Trusted Publishing,无需 `NPM_TOKEN`。
14
+
15
+ ### Changed
16
+
17
+ - `GET /state` 改为纯只读;页面打开时由客户端显式调用 `POST /sweep` 触发节流维护,读请求不再可能触发永久删除。
18
+ - 锁竞争:Host 在报 `LOCKED` 前短暂重试,客户端对 `LOCKED` 自动重试一次;`trash()` 的 lineage/preflight 移到锁外,缩短持锁时间。
19
+ - 收紧 `lib/errors.js` 和 `lib/fsops.js` 的类型(`any` 换成 `unknown` 与具体形状)。
20
+
21
+ ### Tests
22
+
23
+ - 覆盖页面维护扫描(成功/节流/失败)、空无主对象清理、锁重试和客户端 LOCKED 重试。
24
+
7
25
  ## [0.6.4] - 2026-11-11
8
26
 
9
27
  ### Added
@@ -94,6 +112,7 @@
94
112
 
95
113
  - 回收站工具栏的「永久删除全部」与二次确认;`missing` 记录只移出索引,不删除会话文件。
96
114
 
115
+ [0.6.5]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.4...v0.6.5
97
116
  [0.6.4]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.3...v0.6.4
98
117
  [0.6.3]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.2...v0.6.3
99
118
  [0.6.2]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.1...v0.6.2
package/README.en.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-session-trash
2
2
 
3
+ [![CI](https://github.com/wangxiaocuo/dsh-session-trash/actions/workflows/ci.yml/badge.svg)](https://github.com/wangxiaocuo/dsh-session-trash/actions/workflows/ci.yml)
4
+
3
5
  [简体中文](./README.md) | English
4
6
 
5
7
  [GitHub](https://github.com/wangxiaocuo/dsh-session-trash) · [npm](https://www.npmjs.com/package/@wangxiaocuo/dsh-session-trash) · [Changelog](./CHANGELOG.md)
@@ -113,13 +115,15 @@ All routes live under `/plugins/dsh-session-trash/api/` and are registered as ex
113
115
 
114
116
  | Method | Path | Description |
115
117
  |---|---|---|
116
- | `GET` | `/state` | Config, index state, entries, stats, and cleanup diagnostics; may schedule a throttled page-open cleanup |
118
+ | `GET` | `/state` | Config, index state, entries, stats, and cleanup diagnostics (read-only; never sweeps) |
119
+ | `POST` | `/sweep` | No body; runs one throttled page-open maintenance sweep; whether expired entries are deleted depends on `enableAutoCleanup` |
117
120
  | `POST` | `/preflight` | `{ sessionId }` returns `{ eligible: true, descendants }` or `{ eligible: false, code, message, details? }`; `descendants` is the number of hidden children that move along |
118
121
  | `POST` | `/trash` | `{ sessionId }`; moves the whole session tree (parent plus hidden subagent children) and returns `{ ok, entry }` |
119
122
  | `POST` | `/restore` | `{ sessionId, mode: 'normal' \| 'orphan' }` |
120
123
  | `POST` | `/purge` | `{ sessionId }`; permanently deletes the session log |
121
124
  | `POST` | `/purge-all` | No body; permanently deletes every entry in the trash, dropping `missing` index records only; returns `{ ok, purged, forgotten, failed }` |
122
125
  | `POST` | `/forget` | `{ sessionId }`; removes only a `missing` index entry |
126
+ | `POST` | `/orphans/forget` | No body; removes empty unindexed object directories that hold no session log; returns `{ ok, removed }` |
123
127
  | `POST` | `/reveal` | `{ sessionId }`; opens the original workspace |
124
128
  | `POST` | `/cleanup` | No body; runs one cleanup sweep |
125
129
  | `POST` | `/rebuild` | No body; rebuilds the index only in protection mode |
@@ -165,11 +169,12 @@ The index schema is v2; a v1 index is upgraded automatically on startup and pres
165
169
  - Orphan restore never recreates the original workspace directory and never reattaches the session to a workspace.
166
170
  - Archive and pin state survive trash and restore. The Trash page does not manage either set.
167
171
  - Permanent deletion removes the session log only. Attachments, images, projection-cache rows, and search-index rows are not deleted.
172
+ - Unindexed objects (object directories whose `meta.json` is damaged or unreadable) are never deleted automatically; the page can only clean empty leftovers that hold no session log, and anything that may contain logs is left for manual inspection.
168
173
  - `/state` returns all entry summaries in one response. Search, filtering, and paging happen in the Client.
169
174
  - There is no cross-window live push. Another window's change appears after a refresh or when the window regains focus.
170
175
  - The desktop apps on Windows and Linux have not been verified manually; CI boots an npm-installed `dsh web` Host, so desktop-shell UI and desktop-only features (such as reveal in the file manager) are still verified on macOS only. On Windows, cases that rely on POSIX permission bits are skipped, and long paths over 260 characters / symlink copying depend on system configuration (the related tests skip themselves when unsupported).
171
176
  - macOS path comparison is case-insensitive. On a case-sensitive APFS volume, paths that differ only by case are treated as the same location.
172
- - Multiple DSH processes sharing one `DSH_HOME` serialize trash operations through `.lock`. A process paused for more than 10 minutes can lose the lock to another process.
177
+ - Multiple DSH processes sharing one `DSH_HOME` serialize trash operations through `.lock`. The Host retries briefly before reporting `LOCKED`, and the Client retries once automatically; a process paused for more than 10 minutes can lose the lock to another process.
173
178
 
174
179
  ## Development
175
180
 
@@ -177,6 +182,7 @@ The index schema is v2; a v1 index is upgraded automatically on startup and pres
177
182
  pnpm install
178
183
  pnpm test # unit tests + real-DOM component tests (jsdom)
179
184
  pnpm typecheck # tsc --checkJs (currently lib/ and index.js)
185
+ pnpm test:coverage # coverage (CI thresholds apply to lib/**)
180
186
  pnpm test:smoke # Host smoke test (real Cordis, plain Node)
181
187
  npm run test:e2e # real dsh web end-to-end (desktop app or npm-installed DSH)
182
188
  ```
@@ -189,11 +195,20 @@ What CI verifies:
189
195
  |---|---|
190
196
  | Unit tests, Host smoke test | CI: Ubuntu Node 20/22, Windows Node 22 |
191
197
  | Package contents (`npm pack`) | CI: Ubuntu |
198
+ | Coverage thresholds (`lib/**`, lines 85 / branches 70 / functions 84) | CI: Ubuntu Node 22 |
192
199
  | Real `dsh web` end to end (npm-installed DSH) | CI: Ubuntu, Windows (Node 22) |
193
200
  | Real `dsh web` end to end (desktop app) | macOS + DSH 0.2.2-rc.1 (run manually) |
194
201
 
195
202
  `npm run test:e2e` boots a real `dsh web` Host in a temporary `DSH_HOME` and runs the full flow. By default it discovers the desktop app in the platform's usual install locations (or uses `DSH_ELECTRON`); set `DSH_E2E_DSH_BIN` to the `lib/bin.js` of an npm-installed `@deepseek-ai/dsh` to boot the Host with plain Node instead - CI and Linux/Windows use that mode, with no desktop app required. When no Host is found it prints a skip message and exits 0. The cleanup-failure scenario that relies on POSIX permission bits is skipped on Windows. Set `DSH_E2E_KEEP=1` to keep the temporary home and logs.
196
203
 
204
+ ### Releasing (maintainers)
205
+
206
+ 1. Bump the version in `package.json` and update `CHANGELOG.md`, then commit and push.
207
+ 2. Create and push a tag: `git tag -a vX.Y.Z -m "..." && git push origin vX.Y.Z`.
208
+ 3. The tag runs [release.yml](.github/workflows/release.yml): typecheck, unit tests, smoke test, real-host e2e, then `npm publish --provenance` and a GitHub release.
209
+
210
+ Publishing uses npm Trusted Publishing (OIDC), so no `NPM_TOKEN` is required: add repository `wangxiaocuo/dsh-session-trash` and workflow `release.yml` under the package's Settings -> Publishing access -> Trusted publishers on npmjs.com. To use an automation token instead, uncomment the `NODE_AUTH_TOKEN` environment variable in release.yml and add the matching secret.
211
+
197
212
  Project layout:
198
213
 
199
214
  ```text
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-session-trash
2
2
 
3
+ [![CI](https://github.com/wangxiaocuo/dsh-session-trash/actions/workflows/ci.yml/badge.svg)](https://github.com/wangxiaocuo/dsh-session-trash/actions/workflows/ci.yml)
4
+
3
5
  简体中文 | [English](./README.en.md)
4
6
 
5
7
  [GitHub](https://github.com/wangxiaocuo/dsh-session-trash) · [npm](https://www.npmjs.com/package/@wangxiaocuo/dsh-session-trash) · [更新记录](./CHANGELOG.md)
@@ -112,13 +114,15 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
112
114
 
113
115
  | 方法 | 路径 | 说明 |
114
116
  |---|---|---|
115
- | `GET` | `/state` | 配置、索引状态、条目、统计和清理诊断;打开页面时可能触发一次节流清理 |
117
+ | `GET` | `/state` | 配置、索引状态、条目、统计和清理诊断(只读,不触发清理) |
118
+ | `POST` | `/sweep` | 无请求体,按节流执行一次页面维护扫描;回收站页面打开时调用,是否删除过期条目由 `enableAutoCleanup` 决定 |
116
119
  | `POST` | `/preflight` | `{ sessionId }` 返回 `{ eligible: true, descendants }` 或 `{ eligible: false, code, message, details? }`;`descendants` 是一并移入的隐藏子会话数 |
117
120
  | `POST` | `/trash` | `{ sessionId }`,移动整棵会话树(父会话 + 隐藏 subagent 子会话),返回 `{ ok, entry }` |
118
121
  | `POST` | `/restore` | `{ sessionId, mode: 'normal' \| 'orphan' }` |
119
122
  | `POST` | `/purge` | `{ sessionId }`,永久删除会话日志 |
120
123
  | `POST` | `/purge-all` | 无请求体,永久删除回收站中的全部条目;`missing` 记录仅移除索引;返回 `{ ok, purged, forgotten, failed }` |
121
124
  | `POST` | `/forget` | `{ sessionId }`,仅移除 `missing` 条目的索引记录 |
125
+ | `POST` | `/orphans/forget` | 无请求体,删除不含会话日志的空无主对象目录;返回 `{ ok, removed }` |
122
126
  | `POST` | `/reveal` | `{ sessionId }`,打开原工作区 |
123
127
  | `POST` | `/cleanup` | 无请求体,手动执行一次清理 |
124
128
  | `POST` | `/rebuild` | 无请求体,仅在索引保护模式下重建 |
@@ -164,11 +168,12 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
164
168
  - 孤儿恢复不会重建原工作区目录,也不会重新挂到工作区。
165
169
  - 归档和置顶状态会跨回收、恢复保留,回收站页面不管理这两个集合。
166
170
  - 永久删除只删除会话日志。附件、图片、投影缓存和搜索索引不会被删除。
171
+ - 无主对象(`meta.json` 损坏或无法识别的对象目录)不会自动删除;页面只能清理其中不含会话日志的空目录,可能含日志的需要人工处理。
167
172
  - `/state` 一次返回全部条目摘要,页面在客户端搜索、筛选和分页。
168
173
  - 没有跨窗口实时推送。另一个窗口的变更要等刷新或窗口重新获得焦点后出现。
169
174
  - Windows/Linux 的桌面版尚未手动验证;CI 用 npm 安装的 `dsh web` 覆盖真实 Host 行为,但桌面壳 UI 与桌面专属能力(例如「在文件管理器中显示」)仍只在 macOS 上验证。Windows 上依赖 POSIX 权限位的用例会跳过;超过 260 字符的长路径和符号链接复制受系统设置影响,对应用例在不支持时自动跳过。
170
175
  - macOS 的路径比较按大小写不敏感处理。在大小写敏感的 APFS 卷上,仅大小写不同的两个路径会被视为同一位置。
171
- - 多个 DSH 进程共享同一个 `DSH_HOME` 时,回收站操作通过 `.lock` 串行;进程暂停超过 10 分钟后,锁可能被另一个进程接管。
176
+ - 多个 DSH 进程共享同一个 `DSH_HOME` 时,回收站操作通过 `.lock` 串行;Host 在报 `LOCKED` 前会短暂重试,客户端也会自动重试一次;进程暂停超过 10 分钟后,锁可能被另一个进程接管。
172
177
 
173
178
  ## 开发
174
179
 
@@ -176,6 +181,7 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
176
181
  pnpm install
177
182
  pnpm test # 单元测试 + 真实 DOM 组件测试(jsdom)
178
183
  pnpm typecheck # tsc --checkJs(当前覆盖 lib/ 和 index.js)
184
+ pnpm test:coverage # 覆盖率(CI 的门槛只作用于 lib/**)
179
185
  pnpm test:smoke # Host 冒烟测试(真实 Cordis,普通 Node 即可)
180
186
  npm run test:e2e # 真实 dsh web 端到端(桌面版或 npm 安装的 DSH)
181
187
  ```
@@ -188,11 +194,20 @@ Host 冒烟测试通过 `@deepseek-ai/cordis` 这个 devDependency 装配真实
188
194
  |---|---|
189
195
  | 单元测试、Host 冒烟测试 | CI:Ubuntu Node 20/22、Windows Node 22 |
190
196
  | 打包内容(`npm pack`) | CI:Ubuntu |
197
+ | 覆盖率门槛(`lib/**`,行 85 / 分支 70 / 函数 84) | CI:Ubuntu Node 22 |
191
198
  | 真实 `dsh web` 端到端(npm 安装的 DSH) | CI:Ubuntu、Windows(Node 22) |
192
199
  | 真实 `dsh web` 端到端(桌面版) | macOS + DSH 0.2.2-rc.1(手动执行) |
193
200
 
194
201
  `npm run test:e2e` 会启动真实 `dsh web` Host,在临时 `DSH_HOME` 中跑完整流程。默认按平台在常见安装位置查找桌面版(也可以用 `DSH_ELECTRON` 指定);设置 `DSH_E2E_DSH_BIN` 指向 npm 安装的 `@deepseek-ai/dsh` 的 `lib/bin.js` 时,改用普通 Node 启动 Host——CI 和 Linux/Windows 都走这条路径,不依赖桌面版。找不到 Host 时打印跳过信息并以 0 退出。依赖 POSIX 权限位的「清理失败」场景在 Windows 上会跳过。设置 `DSH_E2E_KEEP=1` 可以保留临时目录和日志。
195
202
 
203
+ ### 发布(维护者)
204
+
205
+ 1. 更新 `package.json` 版本和 `CHANGELOG.md`,提交并推送。
206
+ 2. 打 tag 并推送:`git tag -a vX.Y.Z -m "..." && git push origin vX.Y.Z`。
207
+ 3. tag 会触发 [release.yml](.github/workflows/release.yml):跑 typecheck、单测、smoke 和真实 Host e2e,然后 `npm publish --provenance` 并创建 GitHub Release。
208
+
209
+ 发布使用 npm Trusted Publishing(OIDC),不需要 `NPM_TOKEN`:在 npmjs.com 的包设置 → Publishing access → Trusted publishers 里添加仓库 `wangxiaocuo/dsh-session-trash` 和 workflow `release.yml`。如果改用 automation token,取消 release.yml 里 `NODE_AUTH_TOKEN` 的注释并添加同名 secret。
210
+
196
211
  项目结构:
197
212
 
198
213
  ```text
package/client.js CHANGED
@@ -126,6 +126,13 @@ window.__ModuleLoader__.load({
126
126
  corruptBanner: '回收站索引损坏,已进入保护模式。现有会话不会被自动修改。',
127
127
  recoveredBanner: '回收站索引已从备份恢复。',
128
128
  unindexedBanner: '回收站目录中有 {count} 个未索引对象,插件不会自动删除它们。',
129
+ orphansClean: '清理空目录',
130
+ orphansTitle: '清理空的无主对象?',
131
+ orphansBody: '只删除回收站中不含会话日志的残留目录;可能含日志的无主对象会保留。',
132
+ orphansConfirm: '清理空目录',
133
+ orphansDoneOne: '已清理 {count} 个空的无主对象',
134
+ orphansDoneOther: '已清理 {count} 个空的无主对象',
135
+ orphansNone: '没有可安全清理的空目录',
129
136
  rebuild: '尝试重建索引',
130
137
  rebuilding: '正在重建…',
131
138
  rebuildDone: '索引已重建,共 {count} 个条目',
@@ -281,6 +288,13 @@ window.__ModuleLoader__.load({
281
288
  corruptBanner: 'The trash index is damaged, so protection mode is on. No session is changed automatically.',
282
289
  recoveredBanner: 'The trash index was recovered from its backup.',
283
290
  unindexedBanner: 'The trash directory holds {count} unindexed objects. The plugin will not delete them.',
291
+ orphansClean: 'Clean empty leftovers',
292
+ orphansTitle: 'Remove empty unindexed objects?',
293
+ orphansBody: 'Only leftover directories without session logs are removed; unindexed objects that may contain logs are kept.',
294
+ orphansConfirm: 'Clean empty objects',
295
+ orphansDoneOne: 'Removed {count} empty unindexed object',
296
+ orphansDoneOther: 'Removed {count} empty unindexed objects',
297
+ orphansNone: 'No empty leftovers to clean',
284
298
  rebuild: 'Try rebuilding the index',
285
299
  rebuilding: 'Rebuilding…',
286
300
  rebuildDone: 'Index rebuilt with {count} entries',
@@ -1102,6 +1116,10 @@ window.__ModuleLoader__.load({
1102
1116
  heading = format(t('purgeAllTitle'), { count })
1103
1117
  body = t('purgeAllBody')
1104
1118
  confirmLabel = t('purgeAllConfirm')
1119
+ } else if (kind === 'orphans') {
1120
+ heading = t('orphansTitle')
1121
+ body = t('orphansBody')
1122
+ confirmLabel = t('orphansConfirm')
1105
1123
  } else {
1106
1124
  heading = t('forgetTitle')
1107
1125
  body = t('forgetBody')
@@ -1164,6 +1182,7 @@ window.__ModuleLoader__.load({
1164
1182
  const [cleaning, setCleaning] = React.useState(false)
1165
1183
  const [rebuilding, setRebuilding] = React.useState(false)
1166
1184
  const [purgingAll, setPurgingAll] = React.useState(false)
1185
+ const [forgettingOrphans, setForgettingOrphans] = React.useState(false)
1167
1186
  const [announcement, setAnnouncement] = React.useState('')
1168
1187
  const [clock, setClock] = React.useState(() => Date.now())
1169
1188
  const [query, setQuery] = React.useState('')
@@ -1178,6 +1197,8 @@ window.__ModuleLoader__.load({
1178
1197
  const activeWritesRef = React.useRef(0)
1179
1198
  /** Abort scopes of in-flight writes, all cancelled on unmount. */
1180
1199
  const writeScopesRef = React.useRef(new Set())
1200
+ /** The page-open sweep is requested once per mounted section. */
1201
+ const pageSweepRef = React.useRef(false)
1181
1202
  const closeDialog = React.useCallback(() => setDialog(null), [])
1182
1203
 
1183
1204
  /**
@@ -1279,7 +1300,7 @@ window.__ModuleLoader__.load({
1279
1300
  * | { kind: 'network' }
1280
1301
  * >} the outcome.
1281
1302
  */
1282
- async function sendMutation(pathname, body) {
1303
+ async function sendMutationOnce(pathname, body) {
1283
1304
  const scope = openWriteScope()
1284
1305
  try {
1285
1306
  const { status, ok, payload } = await requestJson(pathname, { method: 'POST', body, signal: scope.signal })
@@ -1293,6 +1314,30 @@ window.__ModuleLoader__.load({
1293
1314
  }
1294
1315
  }
1295
1316
 
1317
+ /**
1318
+ * Send one write request. A LOCKED answer means the Host rejected the
1319
+ * call before touching anything, so it is safe to retry once after a
1320
+ * short jitter instead of surfacing a spurious failure.
1321
+ *
1322
+ * @param {string} pathname endpoint below the API prefix.
1323
+ * @param {object} [body] JSON request body.
1324
+ * @returns {Promise<any>} the outcome, see {@link sendMutationOnce}.
1325
+ */
1326
+ async function sendMutation(pathname, body) {
1327
+ let outcome = await sendMutationOnce(pathname, body)
1328
+ if (outcome.kind === 'response' && outcome.ok === false) {
1329
+ const code =
1330
+ outcome.payload !== null && outcome.payload !== undefined && outcome.payload.error !== undefined
1331
+ ? outcome.payload.error.code
1332
+ : undefined
1333
+ if (code === 'LOCKED') {
1334
+ await new Promise((resolve) => setTimeout(resolve, 350 + Math.floor(Math.random() * 350)))
1335
+ outcome = await sendMutationOnce(pathname, body)
1336
+ }
1337
+ }
1338
+ return outcome
1339
+ }
1340
+
1296
1341
  /**
1297
1342
  * @param {'timeout' | 'network'} kind transport failure.
1298
1343
  * @returns {string} the localized message to show.
@@ -1328,6 +1373,12 @@ window.__ModuleLoader__.load({
1328
1373
  }
1329
1374
  setView({ status: 'ready', data: payload, error: null })
1330
1375
  setRowErrors({})
1376
+ // Read-only /state never deletes; ask the Host for the throttled
1377
+ // maintenance sweep explicitly, once per mounted page.
1378
+ if (!pageSweepRef.current) {
1379
+ pageSweepRef.current = true
1380
+ requestJson('sweep', { method: 'POST' }).catch(() => {})
1381
+ }
1331
1382
  })
1332
1383
  .catch(() => {
1333
1384
  if (controller.signal.aborted) {
@@ -1415,7 +1466,8 @@ window.__ModuleLoader__.load({
1415
1466
  const loading = view.status === 'loading'
1416
1467
  const refreshing = view.status === 'refreshing'
1417
1468
  const readsBusy = loading || refreshing
1418
- const pendingCount = pendingRows.size + (cleaning ? 1 : 0) + (purgingAll ? 1 : 0) + (rebuilding ? 1 : 0)
1469
+ const pendingCount =
1470
+ pendingRows.size + (cleaning ? 1 : 0) + (purgingAll ? 1 : 0) + (rebuilding ? 1 : 0) + (forgettingOrphans ? 1 : 0)
1419
1471
  /** Any write (row action or maintenance) is in flight. */
1420
1472
  const mutationBusy = pendingCount > 0
1421
1473
  const corrupt = data !== null && data.index !== undefined && data.index.status === 'corrupt'
@@ -1758,6 +1810,39 @@ window.__ModuleLoader__.load({
1758
1810
  }
1759
1811
  }
1760
1812
 
1813
+ /**
1814
+ * Remove empty unindexed object directories. They contain no session
1815
+ * log, only leftover plugin metadata, so this is safe to offer.
1816
+ *
1817
+ * @returns {Promise<void>} completion of the operation.
1818
+ */
1819
+ async function performPurgeOrphans() {
1820
+ if (!beginWrite()) return
1821
+ setForgettingOrphans(true)
1822
+ try {
1823
+ const outcome = await sendMutation('orphans/forget')
1824
+ if (!aliveRef.current || outcome.kind === 'cancelled') return
1825
+ if (outcome.kind !== 'response') {
1826
+ ctx.contextMenus.notify(transportMessage(outcome.kind), false)
1827
+ return
1828
+ }
1829
+ if (!outcome.ok) {
1830
+ ctx.contextMenus.notify(describeActionFailure(outcome.status, outcome.payload, t), false)
1831
+ return
1832
+ }
1833
+ const payload = outcome.payload
1834
+ const removed =
1835
+ payload !== null && payload !== undefined && typeof payload.removed === 'number' ? payload.removed : 0
1836
+ const message = removed > 0 ? countText(t, 'orphansDone', removed) : t('orphansNone')
1837
+ bumpRevision()
1838
+ ctx.contextMenus.notify(message, removed > 0)
1839
+ announce(message)
1840
+ } finally {
1841
+ endWrite()
1842
+ if (aliveRef.current) setForgettingOrphans(false)
1843
+ }
1844
+ }
1845
+
1761
1846
  /**
1762
1847
  * @param {object} entry wire entry.
1763
1848
  * @param {string} key menu identity.
@@ -1918,6 +2003,16 @@ window.__ModuleLoader__.load({
1918
2003
  'div',
1919
2004
  { className: 'dsh-trash__banner dsh-trash__banner--info', role: 'status', key: 'unindexed' },
1920
2005
  h('span', { className: 'dsh-trash__bannerText' }, format(t('unindexedBanner'), { count: unindexed })),
2006
+ h(
2007
+ 'button',
2008
+ {
2009
+ type: 'button',
2010
+ className: 'dsh-trash__button',
2011
+ onClick: () => setDialog({ kind: 'orphans' }),
2012
+ disabled: loading || mutationBusy,
2013
+ },
2014
+ t('orphansClean'),
2015
+ ),
1921
2016
  ),
1922
2017
  )
1923
2018
  }
@@ -2310,6 +2405,7 @@ window.__ModuleLoader__.load({
2310
2405
  if (current.kind === 'orphan') void performRestore(current.entry, 'orphan')
2311
2406
  else if (current.kind === 'purge') void performPurge(current.entry)
2312
2407
  else if (current.kind === 'purgeAll') void performPurgeAll()
2408
+ else if (current.kind === 'orphans') void performPurgeOrphans()
2313
2409
  else void performForget(current.entry)
2314
2410
  },
2315
2411
  })
package/lib/cleanup.js CHANGED
@@ -68,7 +68,7 @@ function resolveTimer(ctx) {
68
68
  * start: () => void,
69
69
  * stop: () => void,
70
70
  * runNow: (options?: { manual?: boolean, reason?: string }) => Promise<any>,
71
- * maybeSweepOnPageOpen: () => void,
71
+ * sweepOnPageOpen: () => Promise<{ ran: boolean, reason?: string, result?: object }>,
72
72
  * nextSweepAt: () => number | null,
73
73
  * lastSweepAt: () => number,
74
74
  * diagnostics: () => any,
@@ -294,17 +294,24 @@ export function createCleanupService({ ctx, trashService, config, logger }) {
294
294
  runNow,
295
295
 
296
296
  /**
297
- * Trigger a throttled background sweep when the trash page opens. The
297
+ * Run the throttled sweep the trash page requests when it opens. The
298
298
  * timer-backed composition uses a one-hour throttle; without a timer the
299
- * configured sweep interval throttles instead.
299
+ * configured sweep interval throttles instead. This is an explicit POST so
300
+ * the read-only `/state` projection never deletes anything.
300
301
  *
301
- * @returns {void}
302
+ * @returns {Promise<{ ran: boolean, reason?: string, result?: object }>} the sweep outcome.
302
303
  */
303
- maybeSweepOnPageOpen() {
304
- if (stopped) return
304
+ async sweepOnPageOpen() {
305
+ if (stopped) return { ran: false, reason: 'stopped' }
305
306
  const throttle = timer === undefined || !config.enableAutoCleanup ? intervalMs : PAGE_OPEN_THROTTLE_MS
306
- if (lastAttemptAt !== 0 && Date.now() - lastAttemptAt <= throttle) return
307
- void runNow({ reason: 'page-open' }).catch(report)
307
+ if (lastAttemptAt !== 0 && Date.now() - lastAttemptAt <= throttle) return { ran: false, reason: 'throttled' }
308
+ try {
309
+ const result = await runNow({ reason: 'page-open' })
310
+ return { ran: true, result }
311
+ } catch (error) {
312
+ report(error)
313
+ return { ran: false, reason: 'failed' }
314
+ }
308
315
  },
309
316
 
310
317
  /**
package/lib/errors.js CHANGED
@@ -43,7 +43,7 @@ export class TrashError extends Error {
43
43
  /**
44
44
  * @param {string} code stable error code.
45
45
  * @param {string} message owner-facing explanation.
46
- * @param {any} [details] optional structured context.
46
+ * @param {Record<string, unknown>} [details] optional structured context.
47
47
  */
48
48
  constructor(code, message, details) {
49
49
  super(message)
@@ -54,7 +54,7 @@ export class TrashError extends Error {
54
54
  }
55
55
 
56
56
  /**
57
- * @param {any} error arbitrary thrown value.
57
+ * @param {unknown} error arbitrary thrown value.
58
58
  * @returns {number} HTTP status for this error.
59
59
  */
60
60
  export function statusOf(error) {
@@ -86,26 +86,29 @@ const SAFE_DETAIL_KEYS = new Set([
86
86
  * Metrics snapshots are the only nested detail value the Host returns; keep
87
87
  * only their numeric counters so no path can ride along.
88
88
  *
89
- * @param {any} value candidate metrics object.
90
- * @returns {any | undefined} the numeric metrics.
89
+ * @param {unknown} value candidate metrics object.
90
+ * @returns {{ sizeBytes?: number, fileCount?: number, symlinkCount?: number } | undefined} the numeric metrics.
91
91
  */
92
92
  function sanitizeMetrics(value) {
93
93
  if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
94
+ const record = /** @type {Record<string, unknown>} */ (value)
95
+ /** @type {Record<string, unknown>} */
94
96
  const safe = {}
95
97
  for (const key of ['sizeBytes', 'fileCount', 'symlinkCount']) {
96
- if (typeof value[key] === 'number') safe[key] = value[key]
98
+ if (typeof record[key] === 'number') safe[key] = record[key]
97
99
  }
98
100
  return Object.keys(safe).length === 0 ? undefined : safe
99
101
  }
100
102
 
101
103
  /**
102
- * @param {any} details arbitrary structured context.
103
- * @returns {any | undefined} the same context restricted to whitelisted keys.
104
+ * @param {unknown} details arbitrary structured context.
105
+ * @returns {Record<string, unknown> | undefined} the same context restricted to whitelisted keys.
104
106
  */
105
107
  export function sanitizeDetails(details) {
106
108
  if (details === null || typeof details !== 'object' || Array.isArray(details)) return undefined
109
+ /** @type {Record<string, unknown>} */
107
110
  const safe = {}
108
- for (const [key, value] of Object.entries(details)) {
111
+ for (const [key, value] of Object.entries(/** @type {Record<string, unknown>} */ (details))) {
109
112
  if (!SAFE_DETAIL_KEYS.has(key)) continue
110
113
  if (key === 'expected' || key === 'measured') {
111
114
  const metrics = sanitizeMetrics(value)
@@ -122,8 +125,8 @@ export function sanitizeDetails(details) {
122
125
  }
123
126
 
124
127
  /**
125
- * @param {any} error arbitrary thrown value.
126
- * @returns {{ code: string, message: string, details?: object }} the wire error shape.
128
+ * @param {unknown} error arbitrary thrown value.
129
+ * @returns {{ code: string, message: string, details?: Record<string, unknown> }} the wire error shape.
127
130
  */
128
131
  export function toWireError(error) {
129
132
  if (error instanceof TrashError) {
package/lib/fsops.js CHANGED
@@ -36,11 +36,13 @@ const COPY_OPTIONS = {
36
36
  }
37
37
 
38
38
  /**
39
- * @param {any} error thrown filesystem failure.
39
+ * @param {unknown} error thrown filesystem failure.
40
40
  * @returns {string | undefined} the Node error code.
41
41
  */
42
42
  export function errorCode(error) {
43
- return error !== null && typeof error === 'object' && typeof error.code === 'string' ? error.code : undefined
43
+ if (error === null || typeof error !== 'object') return undefined
44
+ const code = /** @type {Record<string, unknown>} */ (error).code
45
+ return typeof code === 'string' ? code : undefined
44
46
  }
45
47
 
46
48
  /**
@@ -194,7 +196,7 @@ export function isInside(root, target) {
194
196
  }
195
197
 
196
198
  /**
197
- * @param {any} trashId candidate object identity.
199
+ * @param {unknown} trashId candidate object identity.
198
200
  * @returns {boolean} whether the identity is one safe path segment.
199
201
  */
200
202
  export function isSafeTrashId(trashId) {
@@ -214,7 +216,7 @@ export function isSafeTrashId(trashId) {
214
216
  * object. Session ids are `session-<uuid>` or a bare uuid today, so the
215
217
  * accepted alphabet is deliberately conservative.
216
218
  *
217
- * @param {any} sessionId candidate member identity.
219
+ * @param {unknown} sessionId candidate member identity.
218
220
  * @returns {boolean} whether the identity is one safe path segment.
219
221
  */
220
222
  export function isSafeMemberId(sessionId) {
@@ -339,7 +341,7 @@ export function createTrashId() {
339
341
  * @param {string} destination final directory path.
340
342
  * @param {{ sizeBytes: number, fileCount: number }} expected recorded metrics.
341
343
  * @param {(from: string, to: string) => Promise<void>} rename rename implementation for tests.
342
- * @param {(source: string, destination: string, options: any) => Promise<void>} copy copy implementation for tests.
344
+ * @param {(source: string, destination: string, options: import('node:fs').CopyOptions) => Promise<void>} copy copy implementation for tests.
343
345
  * @returns {Promise<void>} resolution after the destination is published.
344
346
  */
345
347
  async function copyAcrossDevices(source, destination, expected, rename, copy) {
@@ -382,7 +384,7 @@ async function copyAcrossDevices(source, destination, expected, rename, copy) {
382
384
  * @param {{
383
385
  * logger?: { warn: Function },
384
386
  * rename?: (from: string, to: string) => Promise<void>,
385
- * copy?: (source: string, destination: string, options: any) => Promise<void>,
387
+ * copy?: (source: string, destination: string, options: import('node:fs').CopyOptions) => Promise<void>,
386
388
  * }} [options] diagnostics sink and test seams.
387
389
  * @returns {Promise<void>} resolution after the directory lives at `destination`.
388
390
  */
package/lib/http.js CHANGED
@@ -160,12 +160,13 @@ function requireSessionId(body) {
160
160
  * forget: (sessionId: string) => Promise<any>,
161
161
  * reveal: (sessionId: string, signal?: AbortSignal) => Promise<any>,
162
162
  * rebuild: () => Promise<any>,
163
+ * purgeEmptyOrphans: () => Promise<{ removed: number }>,
163
164
  * },
164
165
  * cleanupService?: {
165
- runNow: (options?: any) => Promise<any>,
166
- maybeSweepOnPageOpen: () => void,
167
- diagnostics?: () => any,
168
- },
166
+ * runNow: (options?: any) => Promise<any>,
167
+ * sweepOnPageOpen: () => Promise<{ ran: boolean, reason?: string, result?: any }>,
168
+ * diagnostics?: () => any,
169
+ * },
169
170
  * ready: Promise<unknown>,
170
171
  * config: any,
171
172
  * logger: any,
@@ -264,11 +265,28 @@ export function registerApi({ ctx, eligibility, stateService, trashService, clea
264
265
 
265
266
  route('state', 'GET', async (_req, res) => {
266
267
  sendJson(res, 200, await stateService.snapshot())
267
- try {
268
- cleanupService?.maybeSweepOnPageOpen()
269
- } catch (error) {
270
- logger.warn(`cannot schedule the page-open trash sweep: ${error instanceof Error ? error.message : String(error)}`)
271
- }
268
+ })
269
+
270
+ route('sweep', 'POST', async (_req, res) => {
271
+ const outcome = await cleanupService.sweepOnPageOpen()
272
+ const result = outcome.result !== null && outcome.result !== undefined ? outcome.result : null
273
+ sendJson(res, 200, {
274
+ ok: true,
275
+ ran: outcome.ran === true,
276
+ ...(outcome.reason === undefined ? {} : { reason: outcome.reason }),
277
+ ...(result === null
278
+ ? {}
279
+ : {
280
+ purged: result.purged ?? 0,
281
+ failed: result.failed ?? 0,
282
+ settled: result.changed ?? 0,
283
+ }),
284
+ })
285
+ })
286
+
287
+ route('orphans/forget', 'POST', async (_req, res) => {
288
+ const result = await trashService.purgeEmptyOrphans()
289
+ sendJson(res, 200, { ok: true, removed: result.removed ?? 0 })
272
290
  })
273
291
 
274
292
  route('preflight', 'POST', async (req, res) => {
package/lib/inventory.js CHANGED
@@ -336,6 +336,30 @@ export async function scanObjects(store, logger) {
336
336
  return { objects, unindexed }
337
337
  }
338
338
 
339
+ /**
340
+ * Plugin-owned sidecar names that may remain inside an otherwise empty object
341
+ * directory. Everything else (session/, children/, stray files) is payload.
342
+ */
343
+ const PLUGIN_OWNED_OBJECT_NAMES = new Set(['meta.json', 'meta.json.tmp'])
344
+
345
+ /**
346
+ * Whether an object directory holds no session payload, only plugin metadata
347
+ * that can be recreated. Used to offer a safe cleanup for unindexed leftovers.
348
+ *
349
+ * @param {import('./store.js').TrashStore} store trash store owning `objects/`.
350
+ * @param {string} trashId object identity.
351
+ * @returns {Promise<boolean>} whether the directory is safe to remove.
352
+ */
353
+ export async function isEmptyObjectDir(store, trashId) {
354
+ if (!isSafeTrashId(trashId)) return false
355
+ try {
356
+ const names = await fs.readdir(store.objectDir(trashId))
357
+ return names.every((name) => PLUGIN_OWNED_OBJECT_NAMES.has(name))
358
+ } catch {
359
+ return false
360
+ }
361
+ }
362
+
339
363
  /**
340
364
  * Import object directories the index does not know about and mark entries
341
365
  * whose object directory vanished. Never deletes a session file or an object
package/lib/store.js CHANGED
@@ -14,6 +14,22 @@ export const LOCK_STALE_MS = 10 * 60 * 1000
14
14
  /** How often a long-running operation refreshes its lock heartbeat. */
15
15
  export const LOCK_REFRESH_MS = 60 * 1000
16
16
 
17
+ /** How many times a caller retries a lock held by another live process. */
18
+ export const LOCK_WAIT_ATTEMPTS = 3
19
+
20
+ /** Base delay between live-lock retries. */
21
+ export const LOCK_WAIT_DELAY_MS = 200
22
+
23
+ /**
24
+ * @param {number} milliseconds delay length.
25
+ * @returns {Promise<void>} resolution after the delay.
26
+ */
27
+ function sleep(milliseconds) {
28
+ return new Promise((resolve) => {
29
+ setTimeout(resolve, milliseconds)
30
+ })
31
+ }
32
+
17
33
  /**
18
34
  * @param {number} [now] timestamp for the new index.
19
35
  * @returns {{ version: number, updatedAt: number, entries: any[] }} an empty index document.
@@ -558,10 +574,15 @@ export class TrashStore {
558
574
  }
559
575
 
560
576
  /**
577
+ * Acquire the cross-process lock. A lock held by another live process is
578
+ * retried for a short window first, so two windows clicking at the same time
579
+ * do not surface a spurious LOCKED error.
580
+ *
561
581
  * @returns {Promise<void>} resolution once this process owns the lock.
562
582
  */
563
583
  async acquireLock() {
564
- for (let attempt = 0; attempt < 3; attempt += 1) {
584
+ let waits = 0
585
+ for (let attempt = 0; attempt < 6; attempt += 1) {
565
586
  try {
566
587
  const handle = await fs.open(this.lockPath, 'wx', 0o600)
567
588
  try {
@@ -576,6 +597,11 @@ export class TrashStore {
576
597
  throw new TrashError('IO_ERROR', `Cannot create the trash lock: ${error instanceof Error ? error.message : String(error)}`)
577
598
  }
578
599
  if (!(await this.lockIsStale())) {
600
+ if (waits < LOCK_WAIT_ATTEMPTS - 1) {
601
+ waits += 1
602
+ await sleep(LOCK_WAIT_DELAY_MS * waits)
603
+ continue
604
+ }
579
605
  throw new TrashError('LOCKED', 'Another trash operation is in progress. Try again in a moment.')
580
606
  }
581
607
  try {
@@ -14,7 +14,7 @@ import {
14
14
  samePath,
15
15
  } from './fsops.js'
16
16
  import { mapLimit } from './concurrency.js'
17
- import { reconcileIndex, rebuildIndex, memberPresence } from './inventory.js'
17
+ import { isEmptyObjectDir, memberPresence, rebuildIndex, reconcileIndex, scanObjects } from './inventory.js'
18
18
  import { aggregateMetrics, memberList } from './members.js'
19
19
 
20
20
  /** How many short retries confirm a moved or restored session is visible. */
@@ -1011,15 +1011,17 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1011
1011
  */
1012
1012
  async function trash(sessionId) {
1013
1013
  const startedAt = Date.now()
1014
+ // Lineage listing and live checks are slow; resolve them before taking the
1015
+ // cross-process lock and keep only the directory re-checks inside it.
1016
+ eligibility.invalidate(sessionId)
1017
+ const verdict = await eligibility.evaluate(sessionId)
1018
+ if (verdict.eligible !== true) {
1019
+ throw new TrashError(verdict.code, verdict.message, verdict.details)
1020
+ }
1014
1021
  return store.withLock(async () => {
1015
1022
  if (store.isProtected()) {
1016
1023
  throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses new entries.')
1017
1024
  }
1018
- eligibility.invalidate(sessionId)
1019
- const verdict = await eligibility.evaluate(sessionId)
1020
- if (verdict.eligible !== true) {
1021
- throw new TrashError(verdict.code, verdict.message, verdict.details)
1022
- }
1023
1025
  const existing = store.findEntry(sessionId)
1024
1026
  if (existing !== undefined) {
1025
1027
  if (existing.state === 'missing') {
@@ -1520,6 +1522,40 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1520
1522
  })
1521
1523
  }
1522
1524
 
1525
+ /**
1526
+ * Remove unindexed object directories that contain no session payload, only
1527
+ * plugin metadata. Directories that may still hold session logs are kept for
1528
+ * manual inspection.
1529
+ *
1530
+ * @returns {Promise<{ removed: number }>} how many empty object directories were removed.
1531
+ */
1532
+ async function purgeEmptyOrphans() {
1533
+ return store.withLock(async () => {
1534
+ if (store.isProtected()) {
1535
+ throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses changes.')
1536
+ }
1537
+ const { objects } = await scanObjects(store, logger)
1538
+ const indexed = new Set(store.entries().map((entry) => entry.trashId))
1539
+ let removed = 0
1540
+ for (const object of objects) {
1541
+ if (indexed.has(object.trashId)) continue
1542
+ if (!(await isEmptyObjectDir(store, object.trashId))) continue
1543
+ try {
1544
+ await store.removeObjectDir(object.trashId)
1545
+ removed += 1
1546
+ logger.info(`removed an empty unindexed trash object (${object.trashId})`)
1547
+ } catch (error) {
1548
+ logger.warn(`cannot remove empty unindexed trash object ${object.trashId}: ${messageOf(error)}`)
1549
+ }
1550
+ }
1551
+ if (removed > 0) {
1552
+ const { unindexed } = await scanObjects(store, logger)
1553
+ store.unindexed = unindexed
1554
+ }
1555
+ return { removed }
1556
+ })
1557
+ }
1558
+
1523
1559
  /**
1524
1560
  * Restore the index from object sidecars after the primary index and its
1525
1561
  * backup were both rejected.
@@ -1741,5 +1777,5 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
1741
1777
  return summary
1742
1778
  }
1743
1779
 
1744
- return { trash, restore, recover, purge, purgeAll, forget, sweep, rebuild, reveal }
1780
+ return { trash, restore, recover, purge, purgeAll, forget, sweep, rebuild, reveal, purgeEmptyOrphans }
1745
1781
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wangxiaocuo/dsh-session-trash",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "DeepSeek Harness plugin: move cold sessions into a recoverable trash, restore them within the retention window, or delete their logs permanently.",
5
5
  "license": "MIT",
6
6
  "author": "wangxiaocuo",