@wangxiaocuo/dsh-session-trash 0.6.3 → 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
@@ -2,6 +2,40 @@
2
2
 
3
3
  本文件记录这个插件的所有重要变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [Unreleased]
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
+
25
+ ## [0.6.4] - 2026-11-11
26
+
27
+ ### Added
28
+
29
+ - e2e 支持 `DSH_E2E_DSH_BIN` 启动 npm 安装的 `@deepseek-ai/dsh` web Host,不再依赖桌面版;Linux/Windows 也能跑真实 Host 端到端测试。
30
+ - CI 新增真实 Host e2e job(Ubuntu + Windows),失败时输出 check annotations。
31
+ - 新增 `pnpm typecheck`(`tsc --checkJs`,先覆盖 `lib/` 和 `index.js`)并接入 CI;DSH 服务通过 `types/dsh-context.d.ts` 的 ambient 声明提供类型。
32
+ - 新增 jsdom + 真实 React 渲染测试(`test/client-dom.test.js`),覆盖行菜单的方向键/Escape/Tab/外点关闭,以及确认对话框的焦点陷阱和焦点恢复。
33
+
34
+ ### Fixed
35
+
36
+ - ActionMenu:Escape 不再冒泡到宿主设置弹层;Tab 会关闭菜单并让焦点自然移动;分隔符使用 `role="separator"`。
37
+ - ConfirmDialog:打开它的行或按钮被移除后,焦点回退到列表面板,不再丢到 `body`。
38
+
5
39
  ## [0.6.3] - 2026-11-11
6
40
 
7
41
  ### Added
@@ -78,6 +112,8 @@
78
112
 
79
113
  - 回收站工具栏的「永久删除全部」与二次确认;`missing` 记录只移出索引,不删除会话文件。
80
114
 
115
+ [0.6.5]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.4...v0.6.5
116
+ [0.6.4]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.3...v0.6.4
81
117
  [0.6.3]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.2...v0.6.3
82
118
  [0.6.2]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.1...v0.6.2
83
119
  [0.6.1]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.0...v0.6.1
package/README.en.md CHANGED
@@ -1,12 +1,14 @@
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)
6
8
 
7
9
  A session trash for DeepSeek Harness. Move cold sessions out of the sidebar without deleting their logs, restore them within the retention window, or delete the logs permanently. Built for DSH 0.2.2-rc.1 on Desktop and Web. Host and Client ship in one package, with no build step.
8
10
 
9
- Status: feature complete. CI (GitHub Actions) runs the unit and Host smoke tests on Ubuntu (Node 20/22) and Windows (Node 22); macOS has run the end-to-end suite against a real DSH Host. The Windows and Linux DSH Hosts have not been verified manually yet, and Windows skips the cases that rely on POSIX permission bits.
11
+ Status: feature complete. CI (GitHub Actions) runs the unit tests, the Host smoke test, and the real `dsh web` end-to-end suite on Ubuntu (Node 20/22) and Windows (Node 22), booting the Host from an npm-installed DSH runtime; macOS also ran the suite against the real desktop Host manually. Windows skips the cases that rely on POSIX permission bits.
10
12
 
11
13
  ## Features
12
14
 
@@ -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,19 +169,22 @@ 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
- - The Windows and Linux DSH Hosts have not been verified manually; CI covers unit and Host smoke tests 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).
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
 
176
181
  ```bash
177
182
  pnpm install
178
- pnpm test # unit tests
183
+ pnpm test # unit tests + real-DOM component tests (jsdom)
184
+ pnpm typecheck # tsc --checkJs (currently lib/ and index.js)
185
+ pnpm test:coverage # coverage (CI thresholds apply to lib/**)
179
186
  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)
187
+ npm run test:e2e # real dsh web end-to-end (desktop app or npm-installed DSH)
181
188
  ```
182
189
 
183
190
  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. `pnpm test` enumerates the test files through `test/run-unit.mjs`, so it does not depend on shell glob expansion and behaves the same on Windows and POSIX shells.
@@ -188,9 +195,19 @@ What CI verifies:
188
195
  |---|---|
189
196
  | Unit tests, Host smoke test | CI: Ubuntu Node 20/22, Windows Node 22 |
190
197
  | Package contents (`npm pack`) | CI: Ubuntu |
191
- | Real `dsh web` end to end | macOS + DSH 0.2.2-rc.1 (run manually) |
198
+ | Coverage thresholds (`lib/**`, lines 85 / branches 70 / functions 84) | CI: Ubuntu Node 22 |
199
+ | Real `dsh web` end to end (npm-installed DSH) | CI: Ubuntu, Windows (Node 22) |
200
+ | Real `dsh web` end to end (desktop app) | macOS + DSH 0.2.2-rc.1 (run manually) |
201
+
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.
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.
192
209
 
193
- `npm run test:e2e` boots a real `dsh web` Host in a temporary `DSH_HOME` and runs the full flow. It discovers the desktop app in the platform's usual install locations, or uses `DSH_ELECTRON`; when no desktop app 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.
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.
194
211
 
195
212
  Project layout:
196
213
 
package/README.md CHANGED
@@ -1,12 +1,14 @@
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)
6
8
 
7
9
  DeepSeek Harness 的会话回收站插件。冷会话可以从侧边栏移出,日志保留在本地;保留期内可以恢复,到期后自动删除日志。面向 DSH 0.2.2-rc.1 的 Desktop 和 Web,Host 和 Client 在同一个包里,不需要构建步骤。
8
10
 
9
- 当前状态:功能已实现。CI(GitHub Actions)在 Ubuntu(Node 20/22)和 Windows(Node 22)上跑单元测试与 Host 冒烟测试;macOS 已在真实 DSH Host 上跑通端到端测试。Windows 和 Linux 的真实 DSH Host 尚未手动验证;Windows 上依赖 POSIX 权限位的用例会显式跳过。
11
+ 当前状态:功能已实现。CI(GitHub Actions)在 Ubuntu(Node 20/22)和 Windows(Node 22)上跑单元测试、Host 冒烟测试和真实 `dsh web` 端到端测试(Host 由 npm 安装的 DSH runtime 启动);macOS 另外在真实桌面版 Host 上手动跑通过 e2e。Windows 上依赖 POSIX 权限位的用例会显式跳过。
10
12
 
11
13
  ## 功能
12
14
 
@@ -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,19 +168,22 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
164
168
  - 孤儿恢复不会重建原工作区目录,也不会重新挂到工作区。
165
169
  - 归档和置顶状态会跨回收、恢复保留,回收站页面不管理这两个集合。
166
170
  - 永久删除只删除会话日志。附件、图片、投影缓存和搜索索引不会被删除。
171
+ - 无主对象(`meta.json` 损坏或无法识别的对象目录)不会自动删除;页面只能清理其中不含会话日志的空目录,可能含日志的需要人工处理。
167
172
  - `/state` 一次返回全部条目摘要,页面在客户端搜索、筛选和分页。
168
173
  - 没有跨窗口实时推送。另一个窗口的变更要等刷新或窗口重新获得焦点后出现。
169
- - Windows 和 Linux 的真实 DSH Host 尚未手动验证;CI 只覆盖单元测试和 Host 冒烟测试。Windows 上依赖 POSIX 权限位的用例会跳过;超过 260 字符的长路径和符号链接复制受系统设置影响,对应用例在不支持时自动跳过。
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
 
175
180
  ```bash
176
181
  pnpm install
177
- pnpm test # 单元测试
182
+ pnpm test # 单元测试 + 真实 DOM 组件测试(jsdom)
183
+ pnpm typecheck # tsc --checkJs(当前覆盖 lib/ 和 index.js)
184
+ pnpm test:coverage # 覆盖率(CI 的门槛只作用于 lib/**)
178
185
  pnpm test:smoke # Host 冒烟测试(真实 Cordis,普通 Node 即可)
179
- npm run test:e2e # 真实 dsh web 端到端(需要本机桌面版)
186
+ npm run test:e2e # 真实 dsh web 端到端(桌面版或 npm 安装的 DSH)
180
187
  ```
181
188
 
182
189
  Host 冒烟测试通过 `@deepseek-ai/cordis` 这个 devDependency 装配真实插件上下文,自己创建并清理临时 `DSH_HOME`,不需要桌面版;设置 `DSH_SMOKE_KEEP=1` 可以保留临时目录用于排查。`pnpm test` 通过 `test/run-unit.mjs` 枚举测试文件,不依赖 shell 展开 glob,Windows 和 POSIX shell 行为一致。
@@ -187,9 +194,19 @@ Host 冒烟测试通过 `@deepseek-ai/cordis` 这个 devDependency 装配真实
187
194
  |---|---|
188
195
  | 单元测试、Host 冒烟测试 | CI:Ubuntu Node 20/22、Windows Node 22 |
189
196
  | 打包内容(`npm pack`) | CI:Ubuntu |
190
- | 真实 `dsh web` 端到端 | macOS + DSH 0.2.2-rc.1(手动执行) |
197
+ | 覆盖率门槛(`lib/**`,行 85 / 分支 70 / 函数 84) | CI:Ubuntu Node 22 |
198
+ | 真实 `dsh web` 端到端(npm 安装的 DSH) | CI:Ubuntu、Windows(Node 22) |
199
+ | 真实 `dsh web` 端到端(桌面版) | macOS + DSH 0.2.2-rc.1(手动执行) |
200
+
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` 可以保留临时目录和日志。
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。
191
208
 
192
- `npm run test:e2e` 会启动真实 `dsh web` Host,在临时 `DSH_HOME` 中跑完整流程。脚本会按平台在常见安装位置查找桌面版,也可以用 `DSH_ELECTRON` 指向其他 Electron 可执行文件;找不到桌面版时打印跳过信息并以 0 退出。依赖 POSIX 权限位的「清理失败」场景在 Windows 上会跳过。设置 `DSH_E2E_KEEP=1` 可以保留临时目录和日志。
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。
193
210
 
194
211
  项目结构:
195
212
 
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',
@@ -889,8 +903,14 @@ window.__ModuleLoader__.load({
889
903
  if (wrapRef.current !== null && !wrapRef.current.contains(event.target)) setOpen(false)
890
904
  }
891
905
  const onKeyDown = (event) => {
906
+ if (event.key === 'Tab') {
907
+ // Let focus leave the menu naturally; just stop leaving it open.
908
+ setOpen(false)
909
+ return
910
+ }
892
911
  if (event.key !== 'Escape') return
893
912
  event.preventDefault()
913
+ event.stopPropagation()
894
914
  setOpen(false)
895
915
  if (buttonRef.current !== null) buttonRef.current.focus()
896
916
  }
@@ -992,9 +1012,11 @@ window.__ModuleLoader__.load({
992
1012
  },
993
1013
  items.map((item, index) =>
994
1014
  h(
995
- 'div',
1015
+ React.Fragment,
996
1016
  { key: item.key },
997
- item.separatorBefore === true && index > 0 ? h('div', { className: 'dsh-trash__menuSeparator' }) : null,
1017
+ item.separatorBefore === true && index > 0
1018
+ ? h('div', { className: 'dsh-trash__menuSeparator', role: 'separator' })
1019
+ : null,
998
1020
  h(
999
1021
  'button',
1000
1022
  {
@@ -1034,7 +1056,7 @@ window.__ModuleLoader__.load({
1034
1056
  * }} props dialog content and handlers.
1035
1057
  * @returns {import('react').ReactElement} the dialog overlay.
1036
1058
  */
1037
- function ConfirmDialog({ kind, entry, count, t, onCancel, onConfirm }) {
1059
+ function ConfirmDialog({ kind, entry, count, t, onCancel, onConfirm, fallbackRef }) {
1038
1060
  const dialogRef = React.useRef(null)
1039
1061
  const cancelRef = React.useRef(null)
1040
1062
  const titleId = `dsh-trash-dialog-title-${kind}`
@@ -1068,7 +1090,12 @@ window.__ModuleLoader__.load({
1068
1090
  document.addEventListener('keydown', onKeyDown, true)
1069
1091
  return () => {
1070
1092
  document.removeEventListener('keydown', onKeyDown, true)
1071
- if (previous !== null && typeof previous.focus === 'function') previous.focus()
1093
+ if (previous !== null && typeof previous.focus === 'function' && previous.isConnected !== false) {
1094
+ previous.focus()
1095
+ return
1096
+ }
1097
+ const fallback = fallbackRef === null || fallbackRef === undefined ? null : fallbackRef.current
1098
+ if (fallback !== null && typeof fallback.focus === 'function') fallback.focus()
1072
1099
  }
1073
1100
  }, [onCancel])
1074
1101
 
@@ -1089,6 +1116,10 @@ window.__ModuleLoader__.load({
1089
1116
  heading = format(t('purgeAllTitle'), { count })
1090
1117
  body = t('purgeAllBody')
1091
1118
  confirmLabel = t('purgeAllConfirm')
1119
+ } else if (kind === 'orphans') {
1120
+ heading = t('orphansTitle')
1121
+ body = t('orphansBody')
1122
+ confirmLabel = t('orphansConfirm')
1092
1123
  } else {
1093
1124
  heading = t('forgetTitle')
1094
1125
  body = t('forgetBody')
@@ -1151,6 +1182,7 @@ window.__ModuleLoader__.load({
1151
1182
  const [cleaning, setCleaning] = React.useState(false)
1152
1183
  const [rebuilding, setRebuilding] = React.useState(false)
1153
1184
  const [purgingAll, setPurgingAll] = React.useState(false)
1185
+ const [forgettingOrphans, setForgettingOrphans] = React.useState(false)
1154
1186
  const [announcement, setAnnouncement] = React.useState('')
1155
1187
  const [clock, setClock] = React.useState(() => Date.now())
1156
1188
  const [query, setQuery] = React.useState('')
@@ -1165,6 +1197,8 @@ window.__ModuleLoader__.load({
1165
1197
  const activeWritesRef = React.useRef(0)
1166
1198
  /** Abort scopes of in-flight writes, all cancelled on unmount. */
1167
1199
  const writeScopesRef = React.useRef(new Set())
1200
+ /** The page-open sweep is requested once per mounted section. */
1201
+ const pageSweepRef = React.useRef(false)
1168
1202
  const closeDialog = React.useCallback(() => setDialog(null), [])
1169
1203
 
1170
1204
  /**
@@ -1266,7 +1300,7 @@ window.__ModuleLoader__.load({
1266
1300
  * | { kind: 'network' }
1267
1301
  * >} the outcome.
1268
1302
  */
1269
- async function sendMutation(pathname, body) {
1303
+ async function sendMutationOnce(pathname, body) {
1270
1304
  const scope = openWriteScope()
1271
1305
  try {
1272
1306
  const { status, ok, payload } = await requestJson(pathname, { method: 'POST', body, signal: scope.signal })
@@ -1280,6 +1314,30 @@ window.__ModuleLoader__.load({
1280
1314
  }
1281
1315
  }
1282
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
+
1283
1341
  /**
1284
1342
  * @param {'timeout' | 'network'} kind transport failure.
1285
1343
  * @returns {string} the localized message to show.
@@ -1315,6 +1373,12 @@ window.__ModuleLoader__.load({
1315
1373
  }
1316
1374
  setView({ status: 'ready', data: payload, error: null })
1317
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
+ }
1318
1382
  })
1319
1383
  .catch(() => {
1320
1384
  if (controller.signal.aborted) {
@@ -1402,7 +1466,8 @@ window.__ModuleLoader__.load({
1402
1466
  const loading = view.status === 'loading'
1403
1467
  const refreshing = view.status === 'refreshing'
1404
1468
  const readsBusy = loading || refreshing
1405
- 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)
1406
1471
  /** Any write (row action or maintenance) is in flight. */
1407
1472
  const mutationBusy = pendingCount > 0
1408
1473
  const corrupt = data !== null && data.index !== undefined && data.index.status === 'corrupt'
@@ -1745,6 +1810,39 @@ window.__ModuleLoader__.load({
1745
1810
  }
1746
1811
  }
1747
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
+
1748
1846
  /**
1749
1847
  * @param {object} entry wire entry.
1750
1848
  * @param {string} key menu identity.
@@ -1905,6 +2003,16 @@ window.__ModuleLoader__.load({
1905
2003
  'div',
1906
2004
  { className: 'dsh-trash__banner dsh-trash__banner--info', role: 'status', key: 'unindexed' },
1907
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
+ ),
1908
2016
  ),
1909
2017
  )
1910
2018
  }
@@ -2269,6 +2377,7 @@ window.__ModuleLoader__.load({
2269
2377
  className: 'dsh-trash__panel',
2270
2378
  key: 'panel',
2271
2379
  ref: panelRef,
2380
+ tabIndex: -1,
2272
2381
  'aria-busy': readsBusy ? 'true' : 'false',
2273
2382
  },
2274
2383
  panelBody,
@@ -2289,12 +2398,14 @@ window.__ModuleLoader__.load({
2289
2398
  count: dialog.count,
2290
2399
  t,
2291
2400
  onCancel: closeDialog,
2401
+ fallbackRef: panelRef,
2292
2402
  onConfirm: () => {
2293
2403
  const current = dialog
2294
2404
  setDialog(null)
2295
2405
  if (current.kind === 'orphan') void performRestore(current.entry, 'orphan')
2296
2406
  else if (current.kind === 'purge') void performPurge(current.entry)
2297
2407
  else if (current.kind === 'purgeAll') void performPurgeAll()
2408
+ else if (current.kind === 'orphans') void performPurgeOrphans()
2298
2409
  else void performForget(current.entry)
2299
2410
  },
2300
2411
  })
package/index.js CHANGED
@@ -34,7 +34,7 @@ function createLogger(ctx) {
34
34
  const prefix = 'dsh-session-trash'
35
35
  let sink = null
36
36
  try {
37
- const factory = ctx.get('logger')
37
+ const factory = /** @type {any} */ (ctx.get('logger'))
38
38
  if (typeof factory === 'function') {
39
39
  const instance = factory(prefix)
40
40
  if (instance !== null && instance !== undefined && typeof instance.info === 'function') sink = instance
@@ -65,7 +65,7 @@ function createLogger(ctx) {
65
65
  * recovery, and the retention sweep.
66
66
  *
67
67
  * @param {import('@deepseek-ai/cordis').Context} ctx plugin context.
68
- * @param {object} config validated plugin configuration.
68
+ * @param {any} config validated plugin configuration.
69
69
  * @returns {void}
70
70
  */
71
71
  export function apply(ctx, config) {
package/lib/cleanup.js CHANGED
@@ -14,7 +14,7 @@ export const CLEANUP_RETRY_MAX_MS = 60 * 60 * 1000
14
14
  const STOPPED_RESULT = Object.freeze({ purged: 0, failed: 0, settled: 0 })
15
15
 
16
16
  /**
17
- * @param {unknown} error arbitrary failure.
17
+ * @param {any} error arbitrary failure.
18
18
  * @returns {string} a concise message.
19
19
  */
20
20
  function messageOf(error) {
@@ -22,7 +22,7 @@ function messageOf(error) {
22
22
  }
23
23
 
24
24
  /**
25
- * @param {unknown} error arbitrary failure.
25
+ * @param {any} error arbitrary failure.
26
26
  * @returns {string} a bounded diagnostic message for `/state`.
27
27
  */
28
28
  function errorText(error) {
@@ -60,18 +60,18 @@ function resolveTimer(ctx) {
60
60
  *
61
61
  * @param {{
62
62
  * ctx: import('@deepseek-ai/cordis').Context,
63
- * trashService: { sweep: (options: { allowPurge: boolean, now: number, reason: string }) => Promise<object> },
63
+ * trashService: { sweep: (options: { allowPurge: boolean, now: number, reason: string }) => Promise<any> },
64
64
  * config: { sweepIntervalHours: number, enableAutoCleanup: boolean },
65
65
  * logger: { info: Function, warn: Function, error: Function, debug: Function },
66
66
  * }} deps host services and the configured cadence.
67
67
  * @returns {{
68
68
  * start: () => void,
69
69
  * stop: () => void,
70
- * runNow: (options?: { manual?: boolean, reason?: string }) => Promise<object>,
71
- * maybeSweepOnPageOpen: () => void,
70
+ * runNow: (options?: { manual?: boolean, reason?: string }) => Promise<any>,
71
+ * sweepOnPageOpen: () => Promise<{ ran: boolean, reason?: string, result?: object }>,
72
72
  * nextSweepAt: () => number | null,
73
73
  * lastSweepAt: () => number,
74
- * diagnostics: () => object,
74
+ * diagnostics: () => any,
75
75
  * }} the cleanup service.
76
76
  */
77
77
  export function createCleanupService({ ctx, trashService, config, logger }) {
@@ -91,7 +91,7 @@ export function createCleanupService({ ctx, trashService, config, logger }) {
91
91
  let disposeRetry = null
92
92
 
93
93
  /**
94
- * @param {unknown} error sweep failure.
94
+ * @param {any} error sweep failure.
95
95
  * @returns {void}
96
96
  */
97
97
  function report(error) {
@@ -148,7 +148,7 @@ export function createCleanupService({ ctx, trashService, config, logger }) {
148
148
  * because an automatic sweep may have been started with `allowPurge: false`.
149
149
  *
150
150
  * @param {{ manual?: boolean, reason?: string }} [options] manual trigger and diagnostic label.
151
- * @returns {Promise<object>} the sweep summary.
151
+ * @returns {Promise<any>} the sweep summary.
152
152
  */
153
153
  async function runNow(options = {}) {
154
154
  if (stopped) return { ...STOPPED_RESULT }
@@ -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
  /**
@@ -324,7 +331,7 @@ export function createCleanupService({ ctx, trashService, config, logger }) {
324
331
  /**
325
332
  * Read-only cleanup diagnostics for `/state` and the settings page.
326
333
  *
327
- * @returns {object} a JSON-safe diagnostic snapshot.
334
+ * @returns {any} a JSON-safe diagnostic snapshot.
328
335
  */
329
336
  diagnostics() {
330
337
  return {
@@ -7,14 +7,14 @@ export const PREFLIGHT_TTL_MS = 2000
7
7
 
8
8
  /**
9
9
  * @typedef {{ sessionId: string, header: object }} DescendantRef
10
- * @typedef {{ sessionId: string, header: object, location: { path: string }, formatStatus?: string }} PlannedMember
10
+ * @typedef {{ sessionId: string, header: any, location: { path: string }, formatStatus?: string }} PlannedMember
11
11
  * @typedef {{ eligible: true, members: PlannedMember[] } | { eligible: false, code: string, message: string, details?: object }} PreflightResult
12
12
  */
13
13
 
14
14
  /**
15
15
  * @param {string} code refusal code.
16
16
  * @param {string} message owner-facing explanation.
17
- * @param {object} [details] optional structured context.
17
+ * @param {any} [details] optional structured context.
18
18
  * @returns {{ eligible: false, code: string, message: string, details?: object }} a refusal.
19
19
  */
20
20
  function refuse(code, message, details) {
@@ -24,7 +24,7 @@ function refuse(code, message, details) {
24
24
  /**
25
25
  * Collect every stored session whose lineage descends from `sessionId`.
26
26
  *
27
- * @param {import('@deepseek-ai/dsh-session-persistence').SessionPersistence} persistence persistence service.
27
+ * @param {any} persistence persistence service.
28
28
  * @param {string} sessionId root session.
29
29
  * @returns {Promise<DescendantRef[]>} descendant identity and stored header.
30
30
  */
@@ -59,7 +59,7 @@ async function descendantsFromListing(persistence, sessionId) {
59
59
  * A grandchild whose direct parent is no longer stored still proves that the
60
60
  * root session owns a lineage the trash must not split.
61
61
  *
62
- * @param {readonly object[]} nodes descendant nodes from the trace.
62
+ * @param {readonly any[]} nodes descendant nodes from the trace.
63
63
  * @returns {DescendantRef[]} every descendant session identity and header.
64
64
  */
65
65
  function collectTraceDescendants(nodes) {
@@ -123,7 +123,7 @@ export function createEligibility({ ctx, store, logger }) {
123
123
 
124
124
  /**
125
125
  * @param {string} sessionId descendant session to validate.
126
- * @returns {Promise<object>} either `{ member }` with the planned member or `{ refusal }`.
126
+ * @returns {Promise<any>} either `{ member }` with the planned member or `{ refusal }`.
127
127
  */
128
128
  async function planDescendant(sessionId) {
129
129
  let snapshot