dsh-conversation-recycle-bin 0.3.13 → 0.3.15

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
@@ -3,6 +3,52 @@
3
3
  All notable changes to `dsh-conversation-recycle-bin` are documented here.
4
4
  The format follows Keep a Changelog; the project uses Semantic Versioning.
5
5
 
6
+ ## [0.3.15] — 2026-10-31
7
+
8
+ dsh-go(Android)适配性审计后的修复。审计结论:Host 半边开箱可用(手机里跑的是真 Node 24 + 原版 DSH 引擎),问题全在**前端座位**与**移动端输入**上。
9
+
10
+ ### Fixed
11
+
12
+ - **原生界面里删除按钮全都没有反应**(dsh-go 侧 `shell.overlay` 是 opt-in 座位,确认框宿主不挂载):新增 `useInlineDialogState()`,检测到 overlay 宿主没有挂载时,把**同一个确认框内联**渲染在设置页「批量删除」标签页里。桌面端/网页端有座位时行为不变(`overlaySeatMounted` 标记 + 500ms 宽限,避免闪一下内联副本)。
13
+ - **`DELETE` 短语在手机上永远对不上**:Android 输入法会把首字母大写,`purgePhrase.trim() !== 'DELETE'` 于是让按钮永久灰掉、没有任何提示。现在 Client 的 `phraseMatches()` 与 Host 的 `deletePlan`/`purgeEntry`/`discardEntry` 三处比对都**忽略大小写并去空格**;三个输入框补 `autoCapitalize`/`autoCorrect`/`spellCheck`(计数短语用 `inputMode: 'numeric'`)。
14
+ - **非 JSON 拒绝被报成「无法解析的响应」**:dsh-go 的 `route-guard.diff` 在插件路由之前返回 `text/plain` 401/503。新增 `httpFallbackError(response)`,按状态码映射成 `E_UNAUTHORIZED`/`E_DISABLED`/`E_BAD_RESPONSE`,并在响应头带 `x-dsh-go-guard: reject` 时把原因写进文案("dsh-go route guard")。
15
+ - **`E_LOCK_BUSY` 是死胡同**:错误信息补上锁文件路径与"持续出现就删掉它"的指引,`details.lockPath` 一并返回(手机被杀进程后残留锁最长阻塞 20 分钟,用户此前没有任何自救手段)。
16
+
17
+ ### Changed
18
+
19
+ - 回收站横幅文案补充「清理只在 Host 运行时发生」:App 关闭期间清扫不执行,下次启动补扫。
20
+ - README 新增 §8.1「dsh-go(Android)适配」:网页模式/原生界面的差异、opt-in 座位导致的降级路径、市场默认隐藏 web-only 插件需点「显示网页版插件」、DSH Remote 无网页版。
21
+
22
+ ### Tests
23
+
24
+ - `test/client-module.test.mjs`(25 → 28):短语忽略大小写与空期望值;非 JSON 拒绝按状态码映射(含 `x-dsh-go-guard` 分支);**没有 overlay 座位时设置页把同一个确认框内联渲染**(无 payload 时不渲染空壳,有 payload 时挂上真正的 `DeleteDialog` 并给出原因提示)。
25
+ - `test/api.test.mjs`(63 → 64):`DELETE` 短语大小写不敏感(`Delete me` 仍 400 `E_CONFIRM_PHRASE`,`delete` 通过)。
26
+ - 合计 188 → **192**(干净树 `rm -rf test/.tmp` 后全绿)。
27
+
28
+ ## [0.3.14] — 2026-10-30
29
+
30
+ 第二轮独立审计(架构 / 安全 / 验收各跑 186 条、并做了变异测试与真实探针)之后的修复:一条会**删掉唯一副本**的读盘失败被当成「空目录」,一条会把用户**刚恢复的对话**再删一次,还有一类条目**三条出路全拒**却仍在 UI 上提供「恢复」按钮。
31
+
32
+ ### Fixed
33
+
34
+ - **`hasFiles()` 不再把「读不到」当成「没有文件」**(`lib/host/api.js`):`catch { return false }` 让 Windows 的 EPERM/EBUSY 读失败等价于「空目录」,`canDiscard` 因此为 true,丢弃该条目会连同唯一的 `memory/` bundle 一起删除。现在只有 `ENOENT`/`ENOTDIR` 才算空,其它错误抛出;`entryDisk()` 捕获后按 **「可能有文件」** 上报,`performDiscard()` 的 `hasFiles` 失败同样按「有文件」拒绝。
35
+ - **保留期清扫不再删除用户刚恢复的会话**(`lib/core/cascade.js`、`lib/host/api.js`):`performPurge` 的 soft 分支只看 `session.live`,不看 `archived`;用户 unarchive 之后,1.5s 的 gateway 缓存窗口内执行清扫会 quiesce 并硬删这个**正在使用的**会话。现在 purge 先判 `archived === false` 并抛 `E_SESSION_ALIVE`(409,新增码),清扫在拿锁后先 `gateway.invalidate()` 再 inspect,被跳过的条目按 6 小时退避,不再每轮刷一次失败。
36
+ - **purge 的状态写入挪到判定之后**:`inspect()` 曾放在 `trash.update(status:'purging')` 之后且不在 try 内,一次 inspect 异常就把条目永久留在 `purging`(既不可恢复也不再被保留期清扫)。
37
+ - **头不可读的日志条目现在有出路**:日志在盘上但读不出 `cwd`(截断、编码不对、缺少 header)时,条目永远到不了终态,而「恢复」会答"没有可恢复的内容"、"丢弃"又说"还持有日志"、purge 因状态不可达 —— 三条路全拒,磁盘空间占着。现在:`entryCapabilities()` 接受 `payloadRecoverable: false`,`canRestore` 为 false、`canDiscard` 为 true;`performRestore` 明确报「日志读不出,请丢弃」;`performDiscard` 在重建路径失败时允许删除(仍要求输入确认短语)。
38
+ - **`ops.hasFiles` 的 `readdir` 结果只在目录存在时解释**,未捕获的非 ENOENT 错误不再被静默吞掉。
39
+
40
+ ### Changed
41
+
42
+ - **`engines.node` 从 `>=20` 提高到 `>=22.15`**:代码用到 22.x 的 `readdir(..., { recursive: true })` 与 `AbortSignal` 行为,而 `>=20` 让 DSH 在 Node 20 上安装本插件后必然崩。
43
+ - 测试夹具 `makeTempHome()` 先 `mkdir test/.tmp`:干净检出上 `mkdtemp` 会 ENOENT,之前能过只是因为某个套件恰好先跑并顺手建了目录(干净树实测 160 pass / 26 fail)。
44
+
45
+ ### Tests
46
+
47
+ - 新增 `session log stage: the mapping is persisted before the directory moves`(真实 `moveToTrash` + 真实文件系统,断言 `beforeMove` 时源目录仍在、目标尚不存在)。把 `catalog.js` 里 `moveDirectory` 与 `beforeMove` 调换后该用例失败——此前这处变异**全套 186 条仍绿**。
48
+ - 新增 `unrecoverable log: not restorable, and discard is the way out`(真实目录 + 头不可读 → `canRestore:false`、`canDiscard:true`、restore 报错文案、discard 成功)。
49
+ - `trash: a corrupt manifest is surfaced as unreadable and discardable once empty` 更新为符合新语义,并补一条「manifest 损坏但 `memory/` 有文件 → 仍拒绝丢弃」。
50
+ - 测试 186 → **188**;干净树(先删 `test/.tmp`)`node --test test/*.test.mjs` 全绿,不再依赖套件执行顺序。
51
+
6
52
  ## [0.3.13] — 2026-10-29
7
53
 
8
54
  2026-10-29 桌面端 / 网页端 bug 复核报告(`AUDIT-2026-10-29-desktop-web.md`)中的 4 条功能缺陷与 1 条文档偏差,本版全部修掉。
package/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  给 DeepSeek Harness (DSH) Web 增加「删除对话」:会话列表的「…」菜单与会话详情页头部各有一个删除入口;确认框内可勾选「同时删除关联记忆」(默认不勾选),并实时显示将被影响的记忆条数。删除默认进入插件回收站,可恢复;每次操作写入审计日志;批量删除有上限与限流,批量软删除需输入数量,彻底删除一键执行;事务任一步失败即回滚。
4
4
 
5
- > 状态:`0.3.13`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
5
+ > 状态:`0.3.14`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
6
+ > 运行要求:**Node.js ≥ 22.15**(Host 侧用到 `readdir(..., { recursive: true })`、`AbortSignal` 等 22.x 行为),DSH ≥ `0.2.1-rc.2`(`package.json` 的 `engines` 已如实声明,DSH 安装校验会据此拒绝更低版本)。
6
7
  > 包名:`dsh-conversation-recycle-bin`(原 `dsh-delete-conversation` 因 npm 被他人占用而改名,见 CHANGELOG 0.2.0)。
7
8
  > 本 README 同时是设计说明与验收依据;安装/回滚步骤见 [INSTALL.md](INSTALL.md);变更见 [CHANGELOG.md](CHANGELOG.md)。
8
9
 
@@ -206,7 +207,7 @@ dsh-conversation-recycle-bin/
206
207
  │ ├─ memory.js # 适配器装配
207
208
  │ ├─ feedback.js # 反馈校验/渲染/投递(http 或 smtp,凭证只读环境变量)
208
209
  │ └─ api.js # 同源 HTTP API、确认 token、限流
209
- └─ test/ # 186 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
210
+ └─ test/ # 188 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
210
211
  ```
211
212
 
212
213
  ---
@@ -246,6 +247,28 @@ npm run check # 语法 + manifest
246
247
 
247
248
  ---
248
249
 
250
+ ## 8.1 dsh-go(Android)适配
251
+
252
+ [dsh-go](https://github.com/ccch1mneyyy/dsh-Go) 的 App 里跑的是**真正的 Node 24 + 原版 DSH 引擎**(PRoot 内 Ubuntu),所以 Host 半边零改动即可运行:`engines.node >= 22.15`、`engines.dsh >= 0.2.1-rc.2` 双向满足,`market/policy.json` 也不拦第三方包。差异全在**前端座位**上:
253
+
254
+ | 界面 | 结果 |
255
+ |---|---|
256
+ | **网页模式**(App 内 DSH 网页版 / SSH 主机) | 完整可用,四个槽位都在 |
257
+ | **原生界面** | 插件能装、设置页有整页岛(回收站可看、可恢复、可彻底清除);会话行菜单与顶栏入口**没有座位**,因此删除走设置页「批量删除」标签页 |
258
+
259
+ 原生界面里 `shell.overlay` 是 opt-in 座位(`slot_catalog.dart` 的 `SeatPolicy.p2Overlay`),确认框的宿主因此不会挂载。0.3.15 起做了两件事:
260
+
261
+ 1. **降级渲染**:检测到 overlay 宿主没有挂载时,设置页把**同一个确认框内联**渲染在「批量删除」标签页里(`client.js` 的 `useInlineDialogState`),不再出现"点了删除没反应"。
262
+ 2. **移动端输入**:短语输入框加 `autoCapitalize/autoCorrect/spellCheck`,并且 Host 与 Client 的短语比对都改成**忽略大小写**(Android 输入法会把 `DELETE` 写成 `Delete`)。
263
+
264
+ 另外两件与手机有关的事,写在文档里而不是藏起来:
265
+
266
+ - **市场默认隐藏 web-only 插件**:安装前要在插件市场点一次「显示网页版插件」。
267
+ - **保留期清理只在 Host 运行时发生**:`trashRetentionDays`(默认 30 天)的清扫由宿主进程驱动,App 被杀掉期间不会执行,下次启动时补扫;`0` 关闭。
268
+ - **DSH Remote(远程桥)没有网页版**,因此插件在那里没有 UI;SSH 主机可以用网页模式。
269
+
270
+ ---
271
+
249
272
  ## 9. 反馈通道(设置页「反馈」按钮)
250
273
 
251
274
  设置页**最上方**新增一条「反馈」横幅(主色按钮,位于标题之上):把问题或建议写成一条消息,由**宿主进程**通过邮件发到维护者邮箱。凭证只在宿主侧读取,页面拿不到、也发不出任何邮件。
package/client.js CHANGED
@@ -112,7 +112,7 @@ window.__ModuleLoader__.load({
112
112
  'trash.title': 'Recycle bin',
113
113
  'trash.empty': 'The recycle bin is empty.',
114
114
  'trash.deletedAt': 'Deleted {time}',
115
- 'trash.retention': 'Entries are purged automatically {days} days after deletion. Set trashRetentionDays to 0 to keep them until you purge them yourself.',
115
+ 'trash.retention': 'Entries are purged automatically {days} days after deletion, but only while the Host is running (a phone with the app closed simply sweeps on the next launch). Set trashRetentionDays to 0 to keep them until you purge them yourself.',
116
116
  'trash.expiresIn': 'auto-purged in {days} d',
117
117
  'trash.expiresSoon': 'auto-purged on the next check',
118
118
  'trash.swept': 'The Host cleaned up {count} expired entries in the background.',
@@ -219,6 +219,7 @@ window.__ModuleLoader__.load({
219
219
  'error.E_QUEUE_TIMEOUT': 'The Host was still busy with another change, so this request was not applied. Retry in a moment.',
220
220
  'error.E_ABANDONED': 'The page gave up waiting before the Host started; nothing was changed. Retry.',
221
221
  'error.E_UNAUTHORIZED': 'This request is not authenticated with the Host.',
222
+ 'dialog.inlineHint': 'This window has no overlay slot (dsh-go native UI), so the confirmation is shown here instead of in a popup.',
222
223
  'adapter.lingshu': 'Lingshu cognitive graph (md_cg)',
223
224
  'adapter.generic-json': 'Generic JSON memory store',
224
225
  'adapter.none': 'No memory store',
@@ -274,7 +275,7 @@ window.__ModuleLoader__.load({
274
275
  'trash.title': '回收站',
275
276
  'trash.empty': '回收站为空。',
276
277
  'trash.deletedAt': '删除于 {time}',
277
- 'trash.retention': '条目在删除 {days} 天后自动清理。把 trashRetentionDays 设为 0 可改为一直保留、由你自己彻底删除。',
278
+ 'trash.retention': '条目在删除 {days} 天后自动清理,但清理只在 Host 运行时发生(手机 App 关闭期间不会执行,下次启动时补扫)。把 trashRetentionDays 设为 0 可改为一直保留、由你自己彻底删除。',
278
279
  'trash.expiresIn': '{days} 天后自动清理',
279
280
  'trash.expiresSoon': '下次检查时自动清理',
280
281
  'trash.swept': 'Host 已在后台自动清理 {count} 条过期条目。',
@@ -381,6 +382,7 @@ window.__ModuleLoader__.load({
381
382
  'error.E_QUEUE_TIMEOUT': '宿主还在处理另一个变更,这次请求没有执行,请稍后重试。',
382
383
  'error.E_ABANDONED': '页面在宿主开始处理前就放弃了等待,本次没有做任何改动,请重试。',
383
384
  'error.E_UNAUTHORIZED': '该请求未通过宿主的鉴权。',
385
+ 'dialog.inlineHint': '当前窗口没有 overlay 槽位(dsh-go 原生界面),确认框直接显示在这里。',
384
386
  'adapter.lingshu': '灵枢认知图 (md_cg)',
385
387
  'adapter.generic-json': '通用 JSON 记忆存储',
386
388
  'adapter.none': '无记忆存储',
@@ -552,6 +554,48 @@ window.__ModuleLoader__.load({
552
554
  return new URL(`${API_PREFIX.replace(/^\//, '')}${path}`, base)
553
555
  }
554
556
 
557
+ /**
558
+ * Does the typed phrase match what the Host asked for?
559
+ *
560
+ * Trimmed and case-insensitive on purpose: Android soft keyboards capitalise
561
+ * the first letter of a word, so `Delete` reached a check that only accepted
562
+ * `DELETE` and the confirm button stayed disabled with no explanation. The
563
+ * phrase exists to make the user think, not to test their keyboard.
564
+ */
565
+ function phraseMatches(input, expected) {
566
+ const want = String(expected ?? '').trim()
567
+ if (want === '') return true
568
+ return String(input ?? '').trim().toUpperCase() === want.toUpperCase()
569
+ }
570
+
571
+ /**
572
+ * Explain a refusal that never reached the plugin route.
573
+ *
574
+ * The dsh-go Host guard (and any reverse proxy in front of DSH) answers with
575
+ * `text/plain`, so `response.json()` threw and the panel showed "unreadable
576
+ * response" where the real answer was "not authenticated".
577
+ */
578
+ function httpFallbackError(response) {
579
+ const status = Number(response?.status)
580
+ let guarded = false
581
+ try {
582
+ guarded = response?.headers?.get?.('x-dsh-go-guard') === 'reject'
583
+ } catch {
584
+ guarded = false
585
+ }
586
+ if (status === 401 || status === 403) {
587
+ return new ApiError('E_UNAUTHORIZED', guarded
588
+ ? 'The Host rejected this request before the plugin saw it (dsh-go route guard): the page is not authenticated.'
589
+ : 'This request is not authenticated with the Host.', { status })
590
+ }
591
+ if (status === 503) {
592
+ return new ApiError('E_DISABLED', guarded
593
+ ? 'The Host route guard has no connection service yet; retry once the app has finished starting.'
594
+ : 'The plugin is disabled, or the Host is not ready yet.', { status })
595
+ }
596
+ return new ApiError('E_BAD_RESPONSE', `The Host answered ${Number.isFinite(status) ? status : 'an unexpected status'} without a JSON body.`, { status })
597
+ }
598
+
555
599
  async function api(path, options = {}) {
556
600
  const method = options.method ?? 'GET'
557
601
  const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : REQUEST_TIMEOUT_MS
@@ -575,7 +619,9 @@ window.__ModuleLoader__.load({
575
619
  payload = null
576
620
  }
577
621
  if (payload === null || typeof payload !== 'object') {
578
- throw new ApiError('E_BAD_RESPONSE', 'The Host returned an unreadable response.')
622
+ // A refusal that never reached the plugin route (Host route guard,
623
+ // reverse proxy, HTML error page) still has a status worth explaining.
624
+ throw httpFallbackError(response)
579
625
  }
580
626
  // A batch that partially succeeded is a RESULT, not a refusal: an older
581
627
  // Host marked it `ok:false` without an `error`, which made the Client
@@ -644,6 +690,38 @@ window.__ModuleLoader__.load({
644
690
  return state
645
691
  }
646
692
 
693
+ /**
694
+ * Whether the `shell.overlay` slot actually gave us a seat.
695
+ *
696
+ * On DSH for desktop/web it always does. On dsh-go's native UI the overlay
697
+ * seat is opt-in (`SeatPolicy.p2Overlay`, `optIn: true` in
698
+ * `slot_catalog.dart`), so the host component never mounts — and with it the
699
+ * only confirmation dialog the delete flow had. Settings still gets a page
700
+ * island, so the panel can render that same dialog inline instead of
701
+ * pretending the button did nothing.
702
+ */
703
+ let overlaySeatMounted = false
704
+
705
+ function useOverlaySeatPresent() {
706
+ const [present, setPresent] = useState(overlaySeatMounted === true)
707
+ useEffect(() => {
708
+ // The overlay host mounts after the slot service resolves; a single
709
+ // frame of grace avoids flashing the inline copy on hosts that do have
710
+ // a seat.
711
+ const timer = setTimeout(() => setPresent(overlaySeatMounted === true), 500)
712
+ return () => clearTimeout(timer)
713
+ }, [])
714
+ return present
715
+ }
716
+
717
+ function useInlineDialogState() {
718
+ const payload = useDialogState()
719
+ const overlayPresent = useOverlaySeatPresent()
720
+ if (overlayPresent || payload === null) return null
721
+ // Feedback has no delete semantics and lives in its own tab.
722
+ return payload.kind === 'feedback' ? null : payload
723
+ }
724
+
647
725
  /**
648
726
  * Notification channel for "a mutation changed the session list". The
649
727
  * dialog refreshes the shell, but the Settings section owns a second copy
@@ -1090,7 +1168,7 @@ window.__ModuleLoader__.load({
1090
1168
  const blocked = Array.isArray(preview?.blocked) ? preview.blocked : []
1091
1169
  const confirmation = preview?.confirmation ?? null
1092
1170
  const expectedPhrase = confirmation?.phrase ?? ''
1093
- const phraseOk = expectedPhrase === '' || phrase.trim() === expectedPhrase
1171
+ const phraseOk = phraseMatches(phrase, expectedPhrase)
1094
1172
  const memory = preview?.memory ?? null
1095
1173
  const memorySupported = memory !== null && memory.enabled === true
1096
1174
  && Array.isArray(memory.adapters) && memory.adapters.some((adapter) => adapter.available === true)
@@ -1267,6 +1345,12 @@ window.__ModuleLoader__.load({
1267
1345
  disabled: busy,
1268
1346
  autoFocus: true,
1269
1347
  className: 'dc-input',
1348
+ // The phrase is a count on a phone: no autocorrect, no
1349
+ // sentence capitalisation, digits where digits are expected.
1350
+ inputMode: 'numeric',
1351
+ autoCorrect: 'off',
1352
+ autoCapitalize: 'off',
1353
+ spellCheck: false,
1270
1354
  }))
1271
1355
  : null,
1272
1356
 
@@ -1480,6 +1564,12 @@ window.__ModuleLoader__.load({
1480
1564
  function DialogHost() {
1481
1565
  const payload = useDialogState()
1482
1566
  const flash = useFlashMessage()
1567
+ useEffect(() => {
1568
+ overlaySeatMounted = true
1569
+ return () => {
1570
+ overlaySeatMounted = false
1571
+ }
1572
+ }, [])
1483
1573
  // `kind` is optional: every pre-0.2.2 caller opens a delete dialog, so a
1484
1574
  // payload without a kind keeps its old meaning.
1485
1575
  const dialog = payload === null
@@ -1595,10 +1685,16 @@ window.__ModuleLoader__.load({
1595
1685
  placeholder: 'DELETE',
1596
1686
  className: 'dc-input dc-input-small',
1597
1687
  onChange: (event) => onPhrase(event.target.value),
1688
+ // Without this Android capitalises the first letter and
1689
+ // writes `Delete`; the guard above is case-insensitive now,
1690
+ // but the keyboard should stop guessing anyway.
1691
+ autoCapitalize: 'characters',
1692
+ autoCorrect: 'off',
1693
+ spellCheck: false,
1598
1694
  }),
1599
1695
  h(Button, {
1600
1696
  variant: 'danger',
1601
- disabled: discardBusyId === entry.trashId || discardPhrase.trim() !== 'DELETE',
1697
+ disabled: discardBusyId === entry.trashId || phraseMatches(discardPhrase, 'DELETE') !== true,
1602
1698
  onClick: () => onDiscard(entry.trashId),
1603
1699
  }, t('trash.discard')),
1604
1700
  h(Button, { disabled: discardBusyId === entry.trashId, onClick: onCancel }, t('action.cancel')))
@@ -1640,6 +1736,7 @@ window.__ModuleLoader__.load({
1640
1736
  function SettingsSection() {
1641
1737
  useLocaleTick()
1642
1738
  const mutationVersion = useMutationVersion()
1739
+ const inlineDialog = useInlineDialogState()
1643
1740
  const [tab, setTab] = useState('trash')
1644
1741
  const [state, setState] = useState(null)
1645
1742
  const [sessions, setSessions] = useState([])
@@ -1966,10 +2063,13 @@ window.__ModuleLoader__.load({
1966
2063
  h('input', {
1967
2064
  type: 'text', value: purgePhrase, placeholder: 'DELETE', className: 'dc-input dc-input-small',
1968
2065
  onChange: (event) => setPurgePhrase(event.target.value),
2066
+ autoCapitalize: 'characters',
2067
+ autoCorrect: 'off',
2068
+ spellCheck: false,
1969
2069
  }),
1970
2070
  h(Button, {
1971
2071
  variant: 'danger',
1972
- disabled: purgeBusy === true || purgePhrase.trim() !== 'DELETE',
2072
+ disabled: purgeBusy === true || phraseMatches(purgePhrase, 'DELETE') !== true,
1973
2073
  onClick: () => { void purge(entry.trashId, purgePhrase.trim()) },
1974
2074
  }, t('action.purge')),
1975
2075
  h(Button, { disabled: purgeBusy === true, onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
@@ -2033,7 +2133,14 @@ window.__ModuleLoader__.load({
2033
2133
  // without the source the two are indistinguishable in the UI.
2034
2134
  + (typeof record.source === 'string' && record.source !== 'web-ui' ? ` · ${record.source}` : '')
2035
2135
  + (record.error?.message ? ` · ${record.error.message}` : ''))),
2036
- h('div', { className: 'dc-row-time' }, record.actor ?? '')))) : null)
2136
+ h('div', { className: 'dc-row-time' }, record.actor ?? '')))) : null,
2137
+
2138
+ // dsh-go's native UI gives `shell.overlay` no seat by default, so the
2139
+ // dialog host never mounts there and every delete button looked dead.
2140
+ // The panel keeps its own seat, so it renders the same dialog inline.
2141
+ inlineDialog === null ? null : h('div', { className: 'dc-inline-dialog' },
2142
+ h('p', { className: 'dc-hint' }, t('dialog.inlineHint')),
2143
+ h(DeleteDialog, { payload: inlineDialog, key: `inline:${inlineDialog.key ?? ''}` })))
2037
2144
  }
2038
2145
 
2039
2146
  /* ------------------------------------------------------------------ *
@@ -2044,6 +2151,11 @@ window.__ModuleLoader__.load({
2044
2151
  /* 1300 clears the settings modal (1000) and the host panels that also use
2045
2152
  1100, since this backlog shares document.body with them. */
2046
2153
  .dc-backdrop{position:fixed;inset:0;z-index:1300;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.38);padding:16px}
2154
+ /* Inline copy for hosts without an overlay seat (dsh-go native UI): the
2155
+ same dialog, in the page flow instead of on top of it. */
2156
+ .dc-inline-dialog{margin-top:14px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l1,#e5e6eb)}
2157
+ .dc-inline-dialog .dc-backdrop{position:static;inset:auto;z-index:auto;background:none;padding:0;display:block}
2158
+ .dc-inline-dialog .dc-dialog{width:100%;max-width:none;max-height:none;box-shadow:none;padding:14px}
2047
2159
  .dc-dialog{box-sizing:border-box;width:min(560px,94vw);max-height:88vh;overflow:auto;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:12px;box-shadow:0 18px 60px rgba(0,0,0,.28);padding:20px 20px 16px;font:inherit}
2048
2160
  .dc-dialog-title{margin:0 0 8px;font-size:16px;font-weight:600;line-height:1.4}
2049
2161
  .dc-dialog-body{margin:0 0 12px;font-size:13px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.6}
@@ -2232,6 +2344,12 @@ window.__ModuleLoader__.load({
2232
2344
  exports.__adapterLabel = adapterLabel
2233
2345
  exports.__adapterReason = adapterReason
2234
2346
  exports.__loadUiFlags = loadUiFlags
2347
+ // Pure helpers the tests assert on directly: the phrase rule and the
2348
+ // non-JSON fallback are exactly where a mobile keyboard or a route
2349
+ // guard changes the outcome.
2350
+ exports.__phraseMatches = phraseMatches
2351
+ exports.__httpFallbackError = httpFallbackError
2352
+ exports.__overlaySeatState = () => ({ mounted: overlaySeatMounted })
2235
2353
  return module.exports
2236
2354
  },
2237
2355
  })
@@ -288,15 +288,21 @@ const PURGEABLE_STATUSES = new Set(['trashed', 'restored'])
288
288
  * the caller, which is the only side that can stat the entry directory.
289
289
  *
290
290
  * @param {{ status?: unknown }} entry
291
- * @param {{ hasLogFiles?: boolean|null, hasMemoryFiles?: boolean|null, requirePayload?: boolean }} [disk]
291
+ * @param {{ hasLogFiles?: boolean|null, hasMemoryFiles?: boolean|null, payloadRecoverable?: boolean|null, requirePayload?: boolean }} [disk]
292
292
  * `requirePayload` makes `canRestore` false for entry states that have no
293
293
  * payload at all (nothing in the manifest and nothing on disk), which is the
294
294
  * answer the list endpoint publishes to the Client.
295
+ * `payloadRecoverable === false` means the log copy is on disk but its header
296
+ * cannot be read: restore has no path to put it back to, so the only action
297
+ * that can reclaim the space is discard.
295
298
  */
296
299
  export function entryCapabilities(entry, disk = {}) {
297
300
  const status = typeof entry?.status === 'string' && entry.status !== '' ? entry.status : 'unknown'
298
301
  const hasLogFiles = disk.hasLogFiles ?? null
299
302
  const hasMemoryFiles = disk.hasMemoryFiles ?? null
303
+ // Unreadable log ≠ restorable log. Offering restore here produced a button
304
+ // that always failed with "discard it", while discard itself refused.
305
+ const unrecoverable = disk.payloadRecoverable === false
300
306
  const hasSessionLog = entry?.sessionLog !== null && entry?.sessionLog !== undefined
301
307
  const hasCatalog = entry?.catalog !== null && entry?.catalog !== undefined
302
308
  const memoryTotal = typeof entry?.memory?.total === 'number' ? entry.memory.total : 0
@@ -307,12 +313,14 @@ export function entryCapabilities(entry, disk = {}) {
307
313
  const hasPayload = hasSessionLog || hasCatalog || memoryTotal > 0 || hasLogFiles === true || hasMemoryFiles === true
308
314
  return {
309
315
  status,
310
- canRestore: RESTORABLE_STATUSES.has(status) && (disk.requirePayload !== true || hasPayload),
316
+ canRestore: RESTORABLE_STATUSES.has(status) && !unrecoverable && (disk.requirePayload !== true || hasPayload),
311
317
  canPurge: PURGEABLE_STATUSES.has(status),
312
318
  // Discard is the escape hatch for entries that never committed: it is
313
319
  // refused while a payload copy still exists, because deleting the folder
314
- // would destroy the only copy of a log or a memory bundle.
315
- canDiscard: status !== 'trashed' && hasLogFiles !== true && hasMemoryFiles !== true,
320
+ // would destroy the only copy of a log or a memory bundle. The one
321
+ // exception is a log whose header cannot be read — nothing can use it any
322
+ // more, so discarding is the only way to get the disk space back.
323
+ canDiscard: status !== 'trashed' && hasMemoryFiles !== true && (unrecoverable || hasLogFiles !== true),
316
324
  }
317
325
  }
318
326
 
@@ -349,6 +357,19 @@ export async function performRestore({ entry, ops, trash, audit, now = Date.now,
349
357
  if (sessionLog === null && typeof ops.recoverSessionLog === 'function') {
350
358
  sessionLog = await ops.recoverSessionLog(entry).catch(() => null)
351
359
  }
360
+ // If the log copy is on disk but its header cannot be read (truncated, wrong
361
+ // codec, missing `cwd`), there is no path to put it back to and no state it
362
+ // can ever reach. Say that plainly instead of "nothing left to restore",
363
+ // which sent the user to a discard that then refused to run.
364
+ if (sessionLog === null && typeof ops.pathExists === 'function' && typeof trash?.entryDir === 'function') {
365
+ const dir = trash.entryDir(entry.trashId)
366
+ if (typeof dir === 'string' && await ops.pathExists(join(dir, 'session')).catch(() => false)) {
367
+ throw new DeleteError('E_TRASH_CONFLICT', 'The conversation log in this recycle-bin entry cannot be read, so it cannot be restored. Discard the entry to reclaim the space.', {
368
+ trashId: entry.trashId,
369
+ reason: 'log-unreadable',
370
+ })
371
+ }
372
+ }
352
373
  const catalog = entry.catalog !== null && entry.catalog !== undefined && typeof entry.catalog === 'object' ? entry.catalog : null
353
374
  let memorySummary = entry.memory !== null && entry.memory !== undefined && typeof entry.memory === 'object' ? entry.memory : null
354
375
  let hasMemory = entry.includeMemory === true && memorySummary !== null && (memorySummary.total ?? 0) > 0
@@ -519,11 +540,35 @@ export async function performPurge({ entry, ops, trash, audit, now = Date.now, a
519
540
  throw new DeleteError('E_TRASH_CONFLICT', 'This recycle-bin entry cannot be purged in its current state.', { trashId: entry.trashId, status: entry.status })
520
541
  }
521
542
  const startedAt = now()
543
+ // Both checks happen BEFORE the status moves to `purging`: doing them after
544
+ // the write left a thrown refusal with the entry stuck in a state that
545
+ // neither restore nor the retention sweep will ever pick up again.
546
+ let softSession = null
547
+ if (entry.mode === 'soft' && typeof ops.session?.inspect === 'function') {
548
+ try {
549
+ softSession = await ops.session.inspect(entry.sessionId)
550
+ } catch (error) {
551
+ throw new DeleteError('E_INTERNAL', 'Could not tell whether the conversation is still visible, so the entry was not purged.', {
552
+ trashId: entry.trashId,
553
+ sessionId: entry.sessionId,
554
+ cause: error instanceof Error ? error.message : String(error),
555
+ }, { cause: error })
556
+ }
557
+ // A soft-deleted entry archived its session. If the user brought it back,
558
+ // the conversation is visible and usable again; purging would stop it and
559
+ // delete it a second time, which is the opposite of what restore promised.
560
+ if (softSession !== null && softSession.archived === false) {
561
+ throw new DeleteError('E_SESSION_ALIVE', 'This conversation is visible in the Host again, so it was not purged. Archive it first if you really want it gone.', {
562
+ trashId: entry.trashId,
563
+ sessionId: entry.sessionId,
564
+ })
565
+ }
566
+ }
522
567
  await trash.update(entry.trashId, { status: 'purging' })
523
568
  const steps = []
524
569
 
525
570
  if (entry.mode === 'soft') {
526
- const session = await ops.session.inspect(entry.sessionId)
571
+ const session = softSession
527
572
  if (session !== null) {
528
573
  if (session.live === true) {
529
574
  steps.push({
@@ -644,16 +689,35 @@ export async function performDiscard({ entry, ops, trash, audit, now = Date.now,
644
689
  const holdsLog = typeof ops.pathExists === 'function'
645
690
  ? await ops.pathExists(join(entryDir, 'session'))
646
691
  : entry.sessionLog !== null && entry.sessionLog !== undefined
647
- if (holdsLog) {
692
+ // A log with no recorded location is normally restorable (the header carries
693
+ // the cwd). When the header is unreadable there is no such path: the log can
694
+ // never go back, so discard is the only way to reclaim the folder — refusing
695
+ // it left the entry stuck in `pending` forever.
696
+ let logUnrecoverable = false
697
+ if (holdsLog === true && (entry.sessionLog === null || entry.sessionLog === undefined) && typeof ops.recoverSessionLog === 'function') {
698
+ logUnrecoverable = (await ops.recoverSessionLog(entry).catch(() => null)) === null
699
+ }
700
+ if (holdsLog === true && !logUnrecoverable) {
648
701
  throw new DeleteError('E_TRASH_CONFLICT', 'This incomplete entry still holds the conversation log; restore or purge it instead of discarding it.', {
649
702
  trashId: entry.trashId,
650
703
  status: entry.status,
651
704
  })
652
705
  }
653
- const holdsMemory = typeof ops.hasFiles === 'function' ? await ops.hasFiles(join(entryDir, 'memory')) : false
706
+ // An unreadable memory folder must count as "holds files": the old `false`
707
+ // default let discard delete the only copy of a bundle it never looked at.
708
+ let holdsMemory = false
709
+ if (typeof ops.hasFiles === 'function') {
710
+ holdsMemory = await ops.hasFiles(join(entryDir, 'memory')).catch(() => true)
711
+ } else {
712
+ holdsMemory = entry.includeMemory === true && (entry.memory?.total ?? 0) > 0
713
+ }
654
714
  // The status half of the decision is shared with the UI; the two refusals
655
715
  // below stay separate because their advice differs (purge vs restore).
656
- const capabilities = entryCapabilities(entry, { hasLogFiles: holdsLog, hasMemoryFiles: holdsMemory })
716
+ const capabilities = entryCapabilities(entry, {
717
+ hasLogFiles: holdsLog === true && !logUnrecoverable,
718
+ hasMemoryFiles: holdsMemory,
719
+ payloadRecoverable: logUnrecoverable ? false : null,
720
+ })
657
721
  if (holdsMemory || !capabilities.canDiscard) {
658
722
  const which = holdsMemory ? 'memory files' : 'payload'
659
723
  throw new DeleteError('E_TRASH_CONFLICT', `This incomplete entry still holds ${which}; restore or purge it instead of discarding it.`, {
@@ -37,6 +37,9 @@ export const ERROR_STATUS = Object.freeze({
37
37
  // know before it retries or refreshes.
38
38
  E_QUEUE_TIMEOUT: 503,
39
39
  E_ABANDONED: 409,
40
+ // A soft-deleted conversation the user brought back: purging it would delete
41
+ // a live conversation a second time.
42
+ E_SESSION_ALIVE: 409,
40
43
  // Feedback channel: its own codes, so the Client can distinguish "this Host
41
44
  // has no mail transport" from "the transport refused the message".
42
45
  E_FEEDBACK_DISABLED: 503,
@@ -103,8 +103,13 @@ export function createFileLock({
103
103
  let breakFailures = 0
104
104
 
105
105
  const busyError = () => {
106
- const busy = new Error(`The lock ${lockPath} is held by another process.`)
106
+ // The path is part of the message on purpose: a lock left behind by a
107
+ // killed Host (or a recycled pid that never heartbeats again) blocks every
108
+ // change for up to twenty minutes, and the only fix the user has is
109
+ // deleting that file. Without the path the refusal is a dead end.
110
+ const busy = new Error(`The lock ${lockPath} is held by another process. If this keeps happening, delete that file and retry.`)
107
111
  busy.code = 'E_LOCK_BUSY'
112
+ busy.details = { lockPath }
108
113
  return busy
109
114
  }
110
115
 
package/lib/host/api.js CHANGED
@@ -405,12 +405,23 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
405
405
  return false
406
406
  }
407
407
  },
408
+ /**
409
+ * Whether a directory holds anything at all. This used to swallow every
410
+ * error and answer `false`, which made "I cannot read it" indistinguishable
411
+ * from "it is empty": a Windows EPERM/EBUSY on the memory folder then read
412
+ * as "no memory here", and discarding that entry deleted the one remaining
413
+ * copy of the bundle. Callers now decide what an unreadable directory
414
+ * means; they all treat it as "may hold files".
415
+ */
408
416
  hasFiles: async (target) => {
409
417
  try {
410
418
  const entries = await readdir(target, { recursive: true })
411
419
  return entries.length > 0
412
- } catch {
413
- return false
420
+ } catch (error) {
421
+ // "Not there" really does mean "nothing to delete"; anything else means
422
+ // we could not look, and that must not be reported as empty.
423
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false
424
+ throw error
414
425
  }
415
426
  },
416
427
  /**
@@ -759,7 +770,12 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
759
770
  // temporary rate limit does not force the user to re-preview.
760
771
  const validated = validateConfirmation(body?.confirmToken, { sessionIds, includeMemory, mode })
761
772
  const phrase = typeof body?.confirmPhrase === 'string' ? body.confirmPhrase.trim() : ''
762
- if (validated.record.phrase !== '' && phrase !== validated.record.phrase) {
773
+ // Compared case-insensitively: Android soft keyboards capitalise the first
774
+ // letter of a sentence, so "Delete" arrived where the Host expected
775
+ // "DELETE" and the only feedback was a refusal with no explanation. The
776
+ // phrase is a deliberate friction, not a spelling test.
777
+ const phraseMatches = validated.record.phrase === '' || phrase.toUpperCase() === validated.record.phrase.toUpperCase()
778
+ if (!phraseMatches) {
763
779
  throw new DeleteError('E_CONFIRM_PHRASE', `Type “${validated.record.phrase}” to confirm this deletion.`, {
764
780
  expected: validated.record.phrase,
765
781
  received: phrase.slice(0, 40),
@@ -890,7 +906,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
890
906
 
891
907
  async function purgeEntry(trashId, confirmPhrase) {
892
908
  if (typeof trashId !== 'string' || trashId === '') throw new DeleteError('E_BAD_REQUEST', 'A recycle-bin id is required.', {})
893
- if (typeof confirmPhrase !== 'string' || confirmPhrase.trim() !== 'DELETE') {
909
+ if (typeof confirmPhrase !== 'string' || confirmPhrase.trim().toUpperCase() !== 'DELETE') {
894
910
  throw new DeleteError('E_CONFIRM_PHRASE', 'Type “DELETE” to permanently delete this entry.', { expected: 'DELETE' })
895
911
  }
896
912
  const entry = await trash.read(trashId)
@@ -916,7 +932,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
916
932
  // still be on disk" branch below and turn this into an arbitrary-path
917
933
  // existence probe.
918
934
  if (!isTrashId(trashId)) throw new DeleteError('E_BAD_REQUEST', 'The recycle-bin id is malformed.', {})
919
- if (typeof confirmPhrase !== 'string' || confirmPhrase.trim() !== 'DELETE') {
935
+ if (typeof confirmPhrase !== 'string' || confirmPhrase.trim().toUpperCase() !== 'DELETE') {
920
936
  throw new DeleteError('E_CONFIRM_PHRASE', 'Type “DELETE” to discard this incomplete entry.', { expected: 'DELETE' })
921
937
  }
922
938
  let entry = await trash.read(trashId)
@@ -1031,18 +1047,34 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1031
1047
  }
1032
1048
 
1033
1049
  /** Disk payload of one entry, for the capability flags the Client renders. */
1034
- async function entryDisk(trashId) {
1035
- if (!isTrashId(trashId)) return { hasLogFiles: false, hasMemoryFiles: false }
1050
+ async function entryDisk(trashId, entry = null) {
1051
+ if (!isTrashId(trashId)) return { hasLogFiles: false, hasMemoryFiles: false, payloadRecoverable: null }
1036
1052
  let dir
1037
1053
  try {
1038
1054
  dir = trash.entryDir(trashId)
1039
1055
  } catch {
1040
- return { hasLogFiles: false, hasMemoryFiles: false }
1056
+ return { hasLogFiles: false, hasMemoryFiles: false, payloadRecoverable: null }
1041
1057
  }
1042
- return {
1043
- hasLogFiles: await ops.pathExists(join(dir, 'session')),
1044
- hasMemoryFiles: await ops.hasFiles(join(dir, 'memory')),
1058
+ const hasLogFiles = await ops.pathExists(join(dir, 'session'))
1059
+ let hasMemoryFiles
1060
+ try {
1061
+ hasMemoryFiles = await ops.hasFiles(join(dir, 'memory'))
1062
+ } catch (error) {
1063
+ // Unreadable is not empty: report the folder as holding files so no
1064
+ // capability flag offers an action that would delete them unseen.
1065
+ logger.warn?.(`[conversation-recycle-bin] could not read the memory folder of ${trashId}: ${error?.message ?? error}`)
1066
+ hasMemoryFiles = true
1067
+ }
1068
+ // A log that is on disk but whose header cannot be read is a dead end: not
1069
+ // restorable (there is no path to put it back to) and not purgeable (the
1070
+ // status never commits). Only "discard" can reclaim that space, so the flag
1071
+ // must say so instead of advertising a restore that always fails.
1072
+ let payloadRecoverable = null
1073
+ if (hasLogFiles === true && entry !== null && (entry.sessionLog === null || entry.sessionLog === undefined)) {
1074
+ const rebuilt = await ops.recoverSessionLog(entry).catch(() => null)
1075
+ payloadRecoverable = rebuilt !== null
1045
1076
  }
1077
+ return { hasLogFiles, hasMemoryFiles, payloadRecoverable }
1046
1078
  }
1047
1079
 
1048
1080
  async function serializeEntry(entry) {
@@ -1052,7 +1084,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1052
1084
  // countdown is therefore decided here, once, by the same predicate the sweep
1053
1085
  // uses (`daysUntilExpiry`), and `expiresAt` carries the absolute deadline.
1054
1086
  const daysLeft = daysUntilExpiry(entry, { retentionDays, now: now() })
1055
- const disk = await entryDisk(entry.trashId)
1087
+ const disk = await entryDisk(entry.trashId, entry)
1056
1088
  return {
1057
1089
  trashId: entry.trashId,
1058
1090
  sessionId: entry.sessionId,
@@ -1172,6 +1204,10 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1172
1204
  return serialize(async () => {
1173
1205
  const purged = []
1174
1206
  const failed = []
1207
+ // The janitor must decide from freshly read state: the gateway caches
1208
+ // `archived` for 1.5s, and a user who unarchives a conversation inside
1209
+ // that window would be judged "still archived" and have it purged.
1210
+ gateway.invalidate()
1175
1211
  for (const candidate of expired) {
1176
1212
  const entry = await trash.read(candidate.trashId)
1177
1213
  if (entry === null || !entryCapabilities(entry).canPurge) continue
@@ -1198,6 +1234,9 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1198
1234
  const running = session.running === true || session.live === true || session.unreadable === true
1199
1235
  if (revived || running || session.archived !== true) {
1200
1236
  failed.push({ trashId: candidate.trashId, code: 'E_SESSION_ALIVE' })
1237
+ // Re-check in hours, not in the next five minutes: a revived
1238
+ // conversation is a human decision, not a race to win.
1239
+ retentionRetries.set(candidate.trashId, { failures: 1, nextAt: now() + RETENTION_RETRY_MAX_MS })
1201
1240
  continue
1202
1241
  }
1203
1242
  }
package/lib/version.js CHANGED
@@ -5,4 +5,4 @@
5
5
  * asserted against it by `test/manifest.test.mjs` so the two can never drift
6
6
  * (they did: the constant stayed at 0.1.0 while the package reached 0.1.4).
7
7
  */
8
- export const PLUGIN_VERSION = '0.3.13'
8
+ export const PLUGIN_VERSION = '0.3.15'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-recycle-bin",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "description": "DSH conversation deletion with an optional, previewed cascade into conversation-associated memory: soft delete + recycle bin, audit trail, batch rate limits and per-session rollback. 删除对话(可选一并删除关联记忆):软删除与回收站、审计留痕、批量限流、失败回滚。",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,7 +33,7 @@
33
33
  "LICENSE"
34
34
  ],
35
35
  "engines": {
36
- "node": ">=20",
36
+ "node": ">=22.15",
37
37
  "dsh": ">=0.2.1-rc.2"
38
38
  },
39
39
  "optionalDependencies": {