@wangxiaocuo/dsh-session-trash 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
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.4] - 2026-11-11
8
+
9
+ ### Added
10
+
11
+ - e2e 支持 `DSH_E2E_DSH_BIN` 启动 npm 安装的 `@deepseek-ai/dsh` web Host,不再依赖桌面版;Linux/Windows 也能跑真实 Host 端到端测试。
12
+ - CI 新增真实 Host e2e job(Ubuntu + Windows),失败时输出 check annotations。
13
+ - 新增 `pnpm typecheck`(`tsc --checkJs`,先覆盖 `lib/` 和 `index.js`)并接入 CI;DSH 服务通过 `types/dsh-context.d.ts` 的 ambient 声明提供类型。
14
+ - 新增 jsdom + 真实 React 渲染测试(`test/client-dom.test.js`),覆盖行菜单的方向键/Escape/Tab/外点关闭,以及确认对话框的焦点陷阱和焦点恢复。
15
+
16
+ ### Fixed
17
+
18
+ - ActionMenu:Escape 不再冒泡到宿主设置弹层;Tab 会关闭菜单并让焦点自然移动;分隔符使用 `role="separator"`。
19
+ - ConfirmDialog:打开它的行或按钮被移除后,焦点回退到列表面板,不再丢到 `body`。
20
+
5
21
  ## [0.6.3] - 2026-11-11
6
22
 
7
23
  ### Added
@@ -78,6 +94,7 @@
78
94
 
79
95
  - 回收站工具栏的「永久删除全部」与二次确认;`missing` 记录只移出索引,不删除会话文件。
80
96
 
97
+ [0.6.4]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.3...v0.6.4
81
98
  [0.6.3]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.2...v0.6.3
82
99
  [0.6.2]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.1...v0.6.2
83
100
  [0.6.1]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.0...v0.6.1
package/README.en.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  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
8
 
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.
9
+ 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
10
 
11
11
  ## Features
12
12
 
@@ -167,7 +167,7 @@ The index schema is v2; a v1 index is upgraded automatically on startup and pres
167
167
  - Permanent deletion removes the session log only. Attachments, images, projection-cache rows, and search-index rows are not deleted.
168
168
  - `/state` returns all entry summaries in one response. Search, filtering, and paging happen in the Client.
169
169
  - 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).
170
+ - 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
171
  - 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
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.
173
173
 
@@ -175,9 +175,10 @@ The index schema is v2; a v1 index is upgraded automatically on startup and pres
175
175
 
176
176
  ```bash
177
177
  pnpm install
178
- pnpm test # unit tests
178
+ pnpm test # unit tests + real-DOM component tests (jsdom)
179
+ pnpm typecheck # tsc --checkJs (currently lib/ and index.js)
179
180
  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)
181
+ npm run test:e2e # real dsh web end-to-end (desktop app or npm-installed DSH)
181
182
  ```
182
183
 
183
184
  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 +189,10 @@ What CI verifies:
188
189
  |---|---|
189
190
  | Unit tests, Host smoke test | CI: Ubuntu Node 20/22, Windows Node 22 |
190
191
  | Package contents (`npm pack`) | CI: Ubuntu |
191
- | Real `dsh web` end to end | macOS + DSH 0.2.2-rc.1 (run manually) |
192
+ | Real `dsh web` end to end (npm-installed DSH) | CI: Ubuntu, Windows (Node 22) |
193
+ | Real `dsh web` end to end (desktop app) | macOS + DSH 0.2.2-rc.1 (run manually) |
192
194
 
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.
195
+ `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.
194
196
 
195
197
  Project layout:
196
198
 
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  DeepSeek Harness 的会话回收站插件。冷会话可以从侧边栏移出,日志保留在本地;保留期内可以恢复,到期后自动删除日志。面向 DSH 0.2.2-rc.1 的 Desktop 和 Web,Host 和 Client 在同一个包里,不需要构建步骤。
8
8
 
9
- 当前状态:功能已实现。CI(GitHub Actions)在 Ubuntu(Node 20/22)和 Windows(Node 22)上跑单元测试与 Host 冒烟测试;macOS 已在真实 DSH Host 上跑通端到端测试。Windows 和 Linux 的真实 DSH Host 尚未手动验证;Windows 上依赖 POSIX 权限位的用例会显式跳过。
9
+ 当前状态:功能已实现。CI(GitHub Actions)在 Ubuntu(Node 20/22)和 Windows(Node 22)上跑单元测试、Host 冒烟测试和真实 `dsh web` 端到端测试(Host 由 npm 安装的 DSH runtime 启动);macOS 另外在真实桌面版 Host 上手动跑通过 e2e。Windows 上依赖 POSIX 权限位的用例会显式跳过。
10
10
 
11
11
  ## 功能
12
12
 
@@ -166,7 +166,7 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
166
166
  - 永久删除只删除会话日志。附件、图片、投影缓存和搜索索引不会被删除。
167
167
  - `/state` 一次返回全部条目摘要,页面在客户端搜索、筛选和分页。
168
168
  - 没有跨窗口实时推送。另一个窗口的变更要等刷新或窗口重新获得焦点后出现。
169
- - Windows 和 Linux 的真实 DSH Host 尚未手动验证;CI 只覆盖单元测试和 Host 冒烟测试。Windows 上依赖 POSIX 权限位的用例会跳过;超过 260 字符的长路径和符号链接复制受系统设置影响,对应用例在不支持时自动跳过。
169
+ - Windows/Linux 的桌面版尚未手动验证;CI 用 npm 安装的 `dsh web` 覆盖真实 Host 行为,但桌面壳 UI 与桌面专属能力(例如「在文件管理器中显示」)仍只在 macOS 上验证。Windows 上依赖 POSIX 权限位的用例会跳过;超过 260 字符的长路径和符号链接复制受系统设置影响,对应用例在不支持时自动跳过。
170
170
  - macOS 的路径比较按大小写不敏感处理。在大小写敏感的 APFS 卷上,仅大小写不同的两个路径会被视为同一位置。
171
171
  - 多个 DSH 进程共享同一个 `DSH_HOME` 时,回收站操作通过 `.lock` 串行;进程暂停超过 10 分钟后,锁可能被另一个进程接管。
172
172
 
@@ -174,9 +174,10 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
174
174
 
175
175
  ```bash
176
176
  pnpm install
177
- pnpm test # 单元测试
177
+ pnpm test # 单元测试 + 真实 DOM 组件测试(jsdom)
178
+ pnpm typecheck # tsc --checkJs(当前覆盖 lib/ 和 index.js)
178
179
  pnpm test:smoke # Host 冒烟测试(真实 Cordis,普通 Node 即可)
179
- npm run test:e2e # 真实 dsh web 端到端(需要本机桌面版)
180
+ npm run test:e2e # 真实 dsh web 端到端(桌面版或 npm 安装的 DSH)
180
181
  ```
181
182
 
182
183
  Host 冒烟测试通过 `@deepseek-ai/cordis` 这个 devDependency 装配真实插件上下文,自己创建并清理临时 `DSH_HOME`,不需要桌面版;设置 `DSH_SMOKE_KEEP=1` 可以保留临时目录用于排查。`pnpm test` 通过 `test/run-unit.mjs` 枚举测试文件,不依赖 shell 展开 glob,Windows 和 POSIX shell 行为一致。
@@ -187,9 +188,10 @@ Host 冒烟测试通过 `@deepseek-ai/cordis` 这个 devDependency 装配真实
187
188
  |---|---|
188
189
  | 单元测试、Host 冒烟测试 | CI:Ubuntu Node 20/22、Windows Node 22 |
189
190
  | 打包内容(`npm pack`) | CI:Ubuntu |
190
- | 真实 `dsh web` 端到端 | macOS + DSH 0.2.2-rc.1(手动执行) |
191
+ | 真实 `dsh web` 端到端(npm 安装的 DSH) | CI:Ubuntu、Windows(Node 22) |
192
+ | 真实 `dsh web` 端到端(桌面版) | macOS + DSH 0.2.2-rc.1(手动执行) |
191
193
 
192
- `npm run test:e2e` 会启动真实 `dsh web` Host,在临时 `DSH_HOME` 中跑完整流程。脚本会按平台在常见安装位置查找桌面版,也可以用 `DSH_ELECTRON` 指向其他 Electron 可执行文件;找不到桌面版时打印跳过信息并以 0 退出。依赖 POSIX 权限位的「清理失败」场景在 Windows 上会跳过。设置 `DSH_E2E_KEEP=1` 可以保留临时目录和日志。
194
+ `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` 可以保留临时目录和日志。
193
195
 
194
196
  项目结构:
195
197
 
package/client.js CHANGED
@@ -889,8 +889,14 @@ window.__ModuleLoader__.load({
889
889
  if (wrapRef.current !== null && !wrapRef.current.contains(event.target)) setOpen(false)
890
890
  }
891
891
  const onKeyDown = (event) => {
892
+ if (event.key === 'Tab') {
893
+ // Let focus leave the menu naturally; just stop leaving it open.
894
+ setOpen(false)
895
+ return
896
+ }
892
897
  if (event.key !== 'Escape') return
893
898
  event.preventDefault()
899
+ event.stopPropagation()
894
900
  setOpen(false)
895
901
  if (buttonRef.current !== null) buttonRef.current.focus()
896
902
  }
@@ -992,9 +998,11 @@ window.__ModuleLoader__.load({
992
998
  },
993
999
  items.map((item, index) =>
994
1000
  h(
995
- 'div',
1001
+ React.Fragment,
996
1002
  { key: item.key },
997
- item.separatorBefore === true && index > 0 ? h('div', { className: 'dsh-trash__menuSeparator' }) : null,
1003
+ item.separatorBefore === true && index > 0
1004
+ ? h('div', { className: 'dsh-trash__menuSeparator', role: 'separator' })
1005
+ : null,
998
1006
  h(
999
1007
  'button',
1000
1008
  {
@@ -1034,7 +1042,7 @@ window.__ModuleLoader__.load({
1034
1042
  * }} props dialog content and handlers.
1035
1043
  * @returns {import('react').ReactElement} the dialog overlay.
1036
1044
  */
1037
- function ConfirmDialog({ kind, entry, count, t, onCancel, onConfirm }) {
1045
+ function ConfirmDialog({ kind, entry, count, t, onCancel, onConfirm, fallbackRef }) {
1038
1046
  const dialogRef = React.useRef(null)
1039
1047
  const cancelRef = React.useRef(null)
1040
1048
  const titleId = `dsh-trash-dialog-title-${kind}`
@@ -1068,7 +1076,12 @@ window.__ModuleLoader__.load({
1068
1076
  document.addEventListener('keydown', onKeyDown, true)
1069
1077
  return () => {
1070
1078
  document.removeEventListener('keydown', onKeyDown, true)
1071
- if (previous !== null && typeof previous.focus === 'function') previous.focus()
1079
+ if (previous !== null && typeof previous.focus === 'function' && previous.isConnected !== false) {
1080
+ previous.focus()
1081
+ return
1082
+ }
1083
+ const fallback = fallbackRef === null || fallbackRef === undefined ? null : fallbackRef.current
1084
+ if (fallback !== null && typeof fallback.focus === 'function') fallback.focus()
1072
1085
  }
1073
1086
  }, [onCancel])
1074
1087
 
@@ -2269,6 +2282,7 @@ window.__ModuleLoader__.load({
2269
2282
  className: 'dsh-trash__panel',
2270
2283
  key: 'panel',
2271
2284
  ref: panelRef,
2285
+ tabIndex: -1,
2272
2286
  'aria-busy': readsBusy ? 'true' : 'false',
2273
2287
  },
2274
2288
  panelBody,
@@ -2289,6 +2303,7 @@ window.__ModuleLoader__.load({
2289
2303
  count: dialog.count,
2290
2304
  t,
2291
2305
  onCancel: closeDialog,
2306
+ fallbackRef: panelRef,
2292
2307
  onConfirm: () => {
2293
2308
  const current = dialog
2294
2309
  setDialog(null)
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>,
70
+ * runNow: (options?: { manual?: boolean, reason?: string }) => Promise<any>,
71
71
  * maybeSweepOnPageOpen: () => void,
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 }
@@ -324,7 +324,7 @@ export function createCleanupService({ ctx, trashService, config, logger }) {
324
324
  /**
325
325
  * Read-only cleanup diagnostics for `/state` and the settings page.
326
326
  *
327
- * @returns {object} a JSON-safe diagnostic snapshot.
327
+ * @returns {any} a JSON-safe diagnostic snapshot.
328
328
  */
329
329
  diagnostics() {
330
330
  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
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 {object} [details] optional structured context.
46
+ * @param {any} [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 {unknown} error arbitrary thrown value.
57
+ * @param {any} error arbitrary thrown value.
58
58
  * @returns {number} HTTP status for this error.
59
59
  */
60
60
  export function statusOf(error) {
@@ -86,8 +86,8 @@ 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 {unknown} value candidate metrics object.
90
- * @returns {object | undefined} the numeric metrics.
89
+ * @param {any} value candidate metrics object.
90
+ * @returns {any | undefined} the numeric metrics.
91
91
  */
92
92
  function sanitizeMetrics(value) {
93
93
  if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
@@ -99,8 +99,8 @@ function sanitizeMetrics(value) {
99
99
  }
100
100
 
101
101
  /**
102
- * @param {unknown} details arbitrary structured context.
103
- * @returns {object | undefined} the same context restricted to whitelisted keys.
102
+ * @param {any} details arbitrary structured context.
103
+ * @returns {any | undefined} the same context restricted to whitelisted keys.
104
104
  */
105
105
  export function sanitizeDetails(details) {
106
106
  if (details === null || typeof details !== 'object' || Array.isArray(details)) return undefined
@@ -122,7 +122,7 @@ export function sanitizeDetails(details) {
122
122
  }
123
123
 
124
124
  /**
125
- * @param {unknown} error arbitrary thrown value.
125
+ * @param {any} error arbitrary thrown value.
126
126
  * @returns {{ code: string, message: string, details?: object }} the wire error shape.
127
127
  */
128
128
  export function toWireError(error) {
package/lib/fsops.js CHANGED
@@ -36,7 +36,7 @@ const COPY_OPTIONS = {
36
36
  }
37
37
 
38
38
  /**
39
- * @param {unknown} error thrown filesystem failure.
39
+ * @param {any} error thrown filesystem failure.
40
40
  * @returns {string | undefined} the Node error code.
41
41
  */
42
42
  export function errorCode(error) {
@@ -194,7 +194,7 @@ export function isInside(root, target) {
194
194
  }
195
195
 
196
196
  /**
197
- * @param {unknown} trashId candidate object identity.
197
+ * @param {any} trashId candidate object identity.
198
198
  * @returns {boolean} whether the identity is one safe path segment.
199
199
  */
200
200
  export function isSafeTrashId(trashId) {
@@ -214,7 +214,7 @@ export function isSafeTrashId(trashId) {
214
214
  * object. Session ids are `session-<uuid>` or a bare uuid today, so the
215
215
  * accepted alphabet is deliberately conservative.
216
216
  *
217
- * @param {unknown} sessionId candidate member identity.
217
+ * @param {any} sessionId candidate member identity.
218
218
  * @returns {boolean} whether the identity is one safe path segment.
219
219
  */
220
220
  export function isSafeMemberId(sessionId) {
@@ -286,8 +286,8 @@ export async function measureDirectory(directory) {
286
286
  }
287
287
 
288
288
  /**
289
- * @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} entry index entry.
290
- * @param {{ sizeBytes: number, fileCount: number }} measured current metrics.
289
+ * @param {{ sizeBytes?: number | null, fileCount?: number | null, symlinkCount?: number | null } | null | undefined} entry index entry.
290
+ * @param {{ sizeBytes: number, fileCount: number, symlinkCount?: number }} measured current metrics.
291
291
  * @returns {boolean} whether the recorded metrics are absent or match exactly.
292
292
  */
293
293
  export function metricsMatch(entry, measured) {
@@ -303,7 +303,7 @@ export function metricsMatch(entry, measured) {
303
303
  }
304
304
 
305
305
  /**
306
- * @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} entry index entry.
306
+ * @param {{ sizeBytes?: number | null, fileCount?: number | null, symlinkCount?: number | null } | null | undefined} entry index entry.
307
307
  * @returns {boolean} whether the entry carries a complete move snapshot.
308
308
  */
309
309
  export function hasMetrics(entry) {
@@ -339,7 +339,7 @@ export function createTrashId() {
339
339
  * @param {string} destination final directory path.
340
340
  * @param {{ sizeBytes: number, fileCount: number }} expected recorded metrics.
341
341
  * @param {(from: string, to: string) => Promise<void>} rename rename implementation for tests.
342
- * @param {(source: string, destination: string, options: object) => Promise<void>} copy copy implementation for tests.
342
+ * @param {(source: string, destination: string, options: any) => Promise<void>} copy copy implementation for tests.
343
343
  * @returns {Promise<void>} resolution after the destination is published.
344
344
  */
345
345
  async function copyAcrossDevices(source, destination, expected, rename, copy) {
@@ -382,7 +382,7 @@ async function copyAcrossDevices(source, destination, expected, rename, copy) {
382
382
  * @param {{
383
383
  * logger?: { warn: Function },
384
384
  * rename?: (from: string, to: string) => Promise<void>,
385
- * copy?: (source: string, destination: string, options: object) => Promise<void>,
385
+ * copy?: (source: string, destination: string, options: any) => Promise<void>,
386
386
  * }} [options] diagnostics sink and test seams.
387
387
  * @returns {Promise<void>} resolution after the directory lives at `destination`.
388
388
  */
package/lib/http.js CHANGED
@@ -45,7 +45,7 @@ function headerBag(req) {
45
45
  /**
46
46
  * @param {import('node:http').ServerResponse} res response to complete.
47
47
  * @param {number} status HTTP status.
48
- * @param {unknown} body JSON-serializable body.
48
+ * @param {any} body JSON-serializable body.
49
49
  * @returns {void}
50
50
  */
51
51
  function sendJson(res, status, body) {
@@ -62,7 +62,7 @@ function sendJson(res, status, body) {
62
62
 
63
63
  /**
64
64
  * @param {import('node:http').ServerResponse} res response to complete.
65
- * @param {unknown} error thrown failure.
65
+ * @param {any} error thrown failure.
66
66
  * @returns {void}
67
67
  */
68
68
  function sendError(res, error) {
@@ -151,20 +151,24 @@ function requireSessionId(body) {
151
151
  * @param {{
152
152
  * ctx: import('@deepseek-ai/cordis').Context,
153
153
  * eligibility: { evaluate: Function, invalidate: Function },
154
- * stateService: { snapshot: () => Promise<object>, describe: (entry: object) => Promise<object> },
154
+ * stateService: { snapshot: () => Promise<any>, describe: (entry: any) => Promise<any> },
155
155
  * trashService: {
156
- * trash: (sessionId: string) => Promise<object>,
157
- * restore: (sessionId: string, mode?: string) => Promise<object>,
158
- * purge: (sessionId: string) => Promise<object>,
159
- * purgeAll: () => Promise<object>,
160
- * forget: (sessionId: string) => Promise<object>,
161
- * reveal: (sessionId: string, signal?: AbortSignal) => Promise<object>,
162
- * rebuild: () => Promise<object>,
156
+ * trash: (sessionId: string) => Promise<any>,
157
+ * restore: (sessionId: string, mode?: string) => Promise<any>,
158
+ * purge: (sessionId: string) => Promise<any>,
159
+ * purgeAll: () => Promise<any>,
160
+ * forget: (sessionId: string) => Promise<any>,
161
+ * reveal: (sessionId: string, signal?: AbortSignal) => Promise<any>,
162
+ * rebuild: () => Promise<any>,
163
163
  * },
164
- * cleanupService?: { runNow: (options?: object) => Promise<object>, maybeSweepOnPageOpen: () => void },
164
+ * cleanupService?: {
165
+ runNow: (options?: any) => Promise<any>,
166
+ maybeSweepOnPageOpen: () => void,
167
+ diagnostics?: () => any,
168
+ },
165
169
  * ready: Promise<unknown>,
166
- * config: object,
167
- * logger: object,
170
+ * config: any,
171
+ * logger: any,
168
172
  * }} deps host services.
169
173
  * @returns {void}
170
174
  */
@@ -292,7 +296,11 @@ export function registerApi({ ctx, eligibility, stateService, trashService, clea
292
296
  route('restore', 'POST', async (req, res) => {
293
297
  const body = await readJsonRequest(req)
294
298
  const sessionId = requireSessionId(body)
295
- const mode = body.mode
299
+ const rawMode = body.mode
300
+ if (rawMode !== undefined && typeof rawMode !== 'string') {
301
+ throw new TrashError('INVALID_REQUEST', 'mode must be "normal" or "orphan".')
302
+ }
303
+ const mode = typeof rawMode === 'string' ? rawMode : undefined
296
304
  if (mode !== undefined && mode !== 'normal' && mode !== 'orphan') {
297
305
  throw new TrashError('INVALID_REQUEST', 'mode must be "normal" or "orphan".')
298
306
  }
package/lib/inventory.js CHANGED
@@ -17,7 +17,7 @@ const META_VERSION = 2
17
17
  const MAX_MEMBERS = 64
18
18
 
19
19
  /**
20
- * @param {unknown} value candidate timestamp.
20
+ * @param {any} value candidate timestamp.
21
21
  * @returns {boolean} whether the value is a finite number.
22
22
  */
23
23
  function isFiniteNumber(value) {
@@ -25,7 +25,7 @@ function isFiniteNumber(value) {
25
25
  }
26
26
 
27
27
  /**
28
- * @param {unknown} value candidate metric.
28
+ * @param {any} value candidate metric.
29
29
  * @returns {boolean} whether the value is a usable number or absent.
30
30
  */
31
31
  function isMetric(value) {
@@ -36,7 +36,7 @@ function isMetric(value) {
36
36
  * Validate one v2 member's absolute paths. The parent's sidecar is validated
37
37
  * field by field so a hand-edited file is rejected instead of guessed at.
38
38
  *
39
- * @param {unknown} member candidate member.
39
+ * @param {any} member candidate member.
40
40
  * @returns {boolean} whether the member names safe, coherent paths.
41
41
  */
42
42
  function isValidMemberPaths(member) {
@@ -72,7 +72,7 @@ function isValidMemberPaths(member) {
72
72
  /**
73
73
  * Validate the v2 member list of one sidecar.
74
74
  *
75
- * @param {object} meta candidate sidecar.
75
+ * @param {any} meta candidate sidecar.
76
76
  * @returns {boolean} whether the list is coherent with its parent fields.
77
77
  */
78
78
  function isValidMetaMembers(meta) {
@@ -100,7 +100,7 @@ function isValidMetaMembers(meta) {
100
100
  * entry. A malformed sidecar is reported as an unindexed object instead of
101
101
  * being guessed at.
102
102
  *
103
- * @param {unknown} meta parsed `meta.json`.
103
+ * @param {any} meta parsed `meta.json`.
104
104
  * @param {string} trashId object directory identity.
105
105
  * @returns {boolean} whether the sidecar can rebuild one entry.
106
106
  */
@@ -132,9 +132,9 @@ export function isValidMeta(meta, trashId) {
132
132
  }
133
133
 
134
134
  /**
135
- * @param {Record<string, unknown>} member raw member fields.
135
+ * @param {any} member raw member fields.
136
136
  * @param {string} fallbackTitle title used when the member carries none.
137
- * @returns {object} one normalized v2 member.
137
+ * @returns {any} one normalized v2 member.
138
138
  */
139
139
  function toMember(member, fallbackTitle) {
140
140
  return {
@@ -151,8 +151,8 @@ function toMember(member, fallbackTitle) {
151
151
  }
152
152
 
153
153
  /**
154
- * @param {Record<string, unknown>} meta valid sidecar.
155
- * @returns {object} a clean index entry with defaulted optional fields.
154
+ * @param {any} meta valid sidecar.
155
+ * @returns {any} a clean index entry with defaulted optional fields.
156
156
  */
157
157
  export function toEntry(meta) {
158
158
  const originalCwd = typeof meta.originalCwd === 'string' && meta.originalCwd !== '' ? meta.originalCwd : null
@@ -201,7 +201,7 @@ export function toEntry(meta) {
201
201
  * Which of one entry's member directories currently exist inside its object.
202
202
  *
203
203
  * @param {import('./store.js').TrashStore} store trash store owning `objects/`.
204
- * @param {object} entry index entry, v1 or v2 shaped.
204
+ * @param {any} entry index entry, v1 or v2 shaped.
205
205
  * @returns {Promise<{ total: number, present: number, missing: string[] }>} presence summary.
206
206
  */
207
207
  export async function memberPresence(store, entry) {
@@ -229,8 +229,8 @@ export async function memberPresence(store, entry) {
229
229
  * Whether two member lists name the same directories in the same order, so a
230
230
  * presence probe taken for one can be reused for the other.
231
231
  *
232
- * @param {readonly object[]} left first member list.
233
- * @param {readonly object[]} right second member list.
232
+ * @param {readonly any[]} left first member list.
233
+ * @param {readonly any[]} right second member list.
234
234
  * @returns {boolean} whether both lists describe the same members.
235
235
  */
236
236
  function sameMembers(left, right) {
@@ -244,10 +244,10 @@ function sameMembers(left, right) {
244
244
  /**
245
245
  * Reconcile a rebuilt entry with the object directory observed on disk.
246
246
  *
247
- * @param {object} entry index-shaped entry.
247
+ * @param {any} entry index-shaped entry.
248
248
  * @param {{ total: number, present: number, missing: string[] }} presence member presence.
249
249
  * @param {number} now current timestamp.
250
- * @returns {object} the entry whose state matches the filesystem.
250
+ * @returns {any} the entry whose state matches the filesystem.
251
251
  */
252
252
  export function deriveEntry(entry, presence, now) {
253
253
  const complete = presence.total > 0 && presence.present === presence.total
@@ -282,7 +282,7 @@ export function deriveEntry(entry, presence, now) {
282
282
  *
283
283
  * @param {import('./store.js').TrashStore} store trash store owning `objects/`.
284
284
  * @param {{ warn: Function }} logger diagnostics sink.
285
- * @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: object | undefined, metaValid: boolean, entry?: object, presence?: object }[], unindexed: number }>} the inventory.
285
+ * @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: any | undefined, metaValid: boolean, entry?: any, presence?: object }[], unindexed: number }>} the inventory.
286
286
  */
287
287
  export async function scanObjects(store, logger) {
288
288
  let entries
@@ -342,7 +342,7 @@ export async function scanObjects(store, logger) {
342
342
  * directory.
343
343
  *
344
344
  * @param {{ store: import('./store.js').TrashStore, logger: { warn: Function }, now?: number }} input trash store and diagnostics.
345
- * @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number, presence: Map<string, object> }>} reconcile summary plus the presence probe per object.
345
+ * @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number, presence: Map<string, any> }>} reconcile summary plus the presence probe per object.
346
346
  */
347
347
  export async function reconcileIndex({ store, logger, now = Date.now() }) {
348
348
  if (store.isProtected()) {