dsh-session-remover 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -3,6 +3,7 @@
3
3
  Adds "Delete conversation" and "Batch delete…" to the sidebar session row "…" menu, moving a conversation and its subagent sessions into the Windows recycle bin, from which they can be restored.
4
4
 
5
5
  - Package: `dsh-session-remover`
6
+ - Version: `1.0.2`
6
7
  - Repository: <https://github.com/sailoumili/dsh-session-remover>
7
8
  - Compatible with DSH 0.1.7 and later; available for the web and desktop editions
8
9
  - Interface text follows the DSH interface language; Chinese and English are both provided
@@ -10,27 +11,17 @@ Adds "Delete conversation" and "Batch delete…" to the sidebar session row "…
10
11
 
11
12
  ## Installation
12
13
 
13
- ### Web
14
-
15
14
  1. Click **Plugins** in the left sidebar.
16
15
  2. Click **Add plugin**.
17
- 3. Enter `dsh-session-remover` in the package field, click **Install**, and wait for the installation to complete.
18
- 4. Enable the plugin on its page.
19
- 5. Restart DSH.
20
-
21
- Equivalent command line:
22
-
23
- ```cmd
24
- dsh plugin --profile web add dsh-session-remover
25
- ```
26
-
27
- ### Desktop
16
+ 3. Enter the following in the package field:
28
17
 
29
- The desktop edition uses a separate plugin manifest (`~/.dsh/profiles/desktop`) and is independent of the web edition, so it requires a separate installation. The steps are identical: sidebar **Plugins** → **Add plugin** → enter the package name → **Install** → enable → restart.
18
+ ```text
19
+ dsh-session-remover
20
+ ```
30
21
 
31
- ```cmd
32
- dsh plugin --profile desktop add dsh-session-remover
33
- ```
22
+ 4. Click **Install** and wait for the installation to complete.
23
+ 5. Enable the plugin on its page.
24
+ 6. Restart DSH.
34
25
 
35
26
  ## Usage
36
27
 
@@ -43,18 +34,19 @@ dsh plugin --profile desktop add dsh-session-remover
43
34
 
44
35
  ## Notes
45
36
 
46
- - **Deletion moves the data to the recycle bin; it does not erase it.** The move stays on the same volume, so no data is copied and nothing is left behind in `.dsh`. Before deleting, the plugin checks whether the recycle bin is disabled for the volume that holds the session (Windows stores this setting per drive); if "Don't move files to the Recycle Bin. Remove files immediately when deleted" is ticked, the plugin **refuses to delete** rather than falling back to permanent deletion. Afterwards each item is verified in the recycle bin by original location and name.
37
+ - **Deletion moves the data to the recycle bin; it does not erase it.** The move stays on the same volume, so no data is copied and nothing is left behind in `.dsh`. Before deleting, the plugin checks two things on the volume that holds the session (Windows stores the recycle-bin setting per drive): whether the recycle bin has been disabled, and whether the volume has a usable recycle bin at all; if either check fails the plugin **refuses to delete** rather than falling back to permanent deletion. Afterwards each item is verified by original location and name; the verification reads the local recycle bin index directly (listing file names only, not reading each entry), so its cost is essentially independent of how many items the bin already holds.
47
38
  - **Subagent sessions are removed together.** Subagent sessions are not shown in the sidebar or search, so they accumulate unless removed together. Forked conversations are independent and are **never** deleted along with their source.
39
+ - **The session directory is located by session id.** The id recorded in the session log header is authoritative. If one id maps to more than one session directory (for example a bare id and a directory with the `session-` prefix side by side), the directory whose name equals the requested id exactly is deleted; when a unique target cannot be determined, the plugin **refuses to delete** and reports an error without changing any data.
48
40
  - **Three records are cleaned:** the projection cache, the workspace registry, and the archived set. The projection cache is derived data that DSH rebuilds after a restore; the other two are cleared to avoid leaving traces locally, at the cost of a restored conversation being filed under "Ungrouped" in the sidebar.
49
41
  - **Deleting the currently open conversation is supported.** Its running task is stopped and flushed to disk first, and no DSH restart is required.
50
42
 
51
43
  ## Known limitations
52
44
 
53
45
  - **Windows only.** Moving to the recycle bin relies on the system script host `cscript.exe`, which ships with Windows; on other systems the plugin refuses to delete and reports an error rather than falling back to permanent deletion.
46
+ - **Deletion is refused when the volume has no recycle bin.** If the session directory sits on a network drive, a USB stick, or a volume with no usable recycle bin (such as FAT/exFAT), moving it to the recycle bin would degrade into permanent deletion, so the plugin always refuses. To proceed, move the session directory to a local disk, or delete any file on that volume once to initialise its recycle bin, and try again.
54
47
  - **An emptied recycle bin cannot be restored.** The plugin only moves the files into the bin; whether to empty it is the user's decision. The bin also has a size cap, and Windows discards the oldest entries once the quota is exceeded.
55
- - **A restored conversation may not be in its original workspace.** The plugin clears the workspace registry entry, so a restored conversation appears under "Ungrouped". To restore it to its original workspace, edit `~/.dsh/storages/workspace.json` by hand while DSH is stopped.
48
+ - **A restored conversation may not be in its original workspace.** The plugin clears the workspace registry entry, so a restored conversation appears under "Ungrouped". To restore it to its original workspace, ask the AI assistant to update `~/.dsh/storages/workspace.json` while DSH is stopped.
56
49
  - **Attachments are not reclaimed.** DSH attachments are content-addressed, shared across conversations, and carry no reference counting, so the plugin cannot safely determine whether they can be reclaimed.
57
- - **On a failed delete, the reason text returned by the server also follows the interface language**: the server returns only an error code and parameters, and the interface text is resolved by the front end in the active language.
58
50
  - **The endpoint accepts loopback requests only.** `/api/session.delete` allows only `127.0.0.1` / `localhost` / `[::1]` and returns 403 for LAN origins. The plugin registers its route outside the official authentication fence, while the official browser-cookie check depends on a signing secret the plugin cannot obtain; deleting data is high-risk, so it fails closed. If DSH is bound to `0.0.0.0` and accessed from another machine, deletion returns 403.
59
51
  - **Nothing is touched when the session directory cannot be read.** A failed session-tree scan aborts the deletion as a whole and never treats "read failure" as "absent from disk".
60
52
  - The batch delete panel does not trap focus; `Tab` may move to page elements behind the panel.
package/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  在侧栏会话行的「…」菜单中增加「删除对话」与「批量删除…」两项,将对话及其子代理会话移入 Windows 回收站,需要时可予以还原。
4
4
 
5
5
  - 包名:`dsh-session-remover`
6
+ - 版本:`1.0.2`
6
7
  - 仓库:<https://github.com/sailoumili/dsh-session-remover>
7
8
  - 兼容 DSH 0.1.7 及以上版本,适用于网页版与桌面版
8
9
  - 界面文字随 DSH 界面语言切换,提供中文与英文两套
@@ -10,27 +11,17 @@
10
11
 
11
12
  ## 安装
12
13
 
13
- ### 网页版
14
-
15
14
  1. 在左侧栏单击「插件」。
16
15
  2. 单击「添加插件」。
17
- 3. 在「包名或地址」输入框中填写 `dsh-session-remover`,单击「安装」,等待安装完成。
18
- 4. 在该插件页面中启用该插件。
19
- 5. 重启 DSH。
20
-
21
- 等效命令行:
22
-
23
- ```cmd
24
- dsh plugin --profile web add dsh-session-remover
25
- ```
26
-
27
- ### 桌面版
16
+ 3. 在「包名或地址」输入框中填写:
28
17
 
29
- 桌面版使用独立的插件清单(`~/.dsh/profiles/desktop`),与网页版互不影响,须单独安装一次。操作步骤与网页版一致:左侧栏「插件」→「添加插件」→ 填写包名 →「安装」→ 启用 → 重启。
18
+ ```text
19
+ dsh-session-remover
20
+ ```
30
21
 
31
- ```cmd
32
- dsh plugin --profile desktop add dsh-session-remover
33
- ```
22
+ 4. 单击「安装」,等待安装完成。
23
+ 5. 在该插件页面中启用该插件。
24
+ 6. 重启 DSH。
34
25
 
35
26
  ## 用法
36
27
 
@@ -43,18 +34,19 @@ dsh plugin --profile desktop add dsh-session-remover
43
34
 
44
35
  ## 说明
45
36
 
46
- - **删除为移入回收站,并非抹除**:同盘移动,不复制数据,`.dsh` 内不留副本。删除前先按会话所在磁盘核对回收站是否被关闭(Windows 该开关按磁盘逐个设置);已勾选「不将文件移到回收站中,直接删除」时,插件**拒绝删除**,不会退化为永久删除。移入后另按「原位置 + 名字」在回收站中逐项核对,确认确实进入回收站。
37
+ - **删除为移入回收站,并非抹除**:同盘移动,不复制数据,`.dsh` 内不留副本。删除前先按会话所在磁盘核对两件事:该磁盘是否关闭了回收站(Windows 该开关按磁盘逐个设置)、该磁盘是否有可用的回收站;任一不满足即**拒绝删除**,不会退化为永久删除。移入后另按「原位置 + 名字」逐项核对,确认确实进入回收站;核对直接读取本机回收站索引(只列文件名,不逐项读取),耗时基本不受回收站已存项数影响。
47
38
  - **一并删除子代理会话**:子代理会话不显示于侧栏与搜索,未一并清理将持续累积。由 fork 产生的分叉会话属独立对话,**不被连带删除**。
39
+ - **按会话编号定位目录**:编号以会话日志头部记录的编号为准。同一编号若对应多个会话目录(例如裸编号与带 `session-` 前缀的目录并存),优先删除目录名与所传编号完全一致的那个;无法确定唯一目标时**拒绝删除**并报错,不改动任何数据。
48
40
  - **清理三处记录**:投影缓存、工作区记账、归档集合。投影缓存为派生数据,还原后由 DSH 自动重建;后两处予以清理以避免在本地留存痕迹,代价是还原后的对话归入侧栏的「未分组」。
49
41
  - **支持删除当前打开的对话**:删除前先停止其运行中的任务并落盘,无需重启 DSH。
50
42
 
51
43
  ## 已知限制
52
44
 
53
45
  - **仅支持 Windows**。移入回收站依赖系统脚本宿主 `cscript.exe`(Windows 自带);在其他系统上插件拒绝删除并报错,不会退化为永久删除。
46
+ - **所在磁盘没有回收站时拒绝删除**。会话目录若位于网络盘、U 盘或未启用回收站的磁盘(如 FAT/exFAT),移入回收站会退化为永久删除,插件一律拒绝。处理办法:先把会话目录搬到本地磁盘,或在该磁盘上删除任意一个文件以启用回收站,再重试。
54
47
  - **回收站清空后不可还原**。插件仅负责将文件移入回收站,是否清空由用户决定;回收站存在容量上限,超出配额时 Windows 将丢弃最早的项。
55
- - **还原后可能不在原工作区**。插件清除了工作区记账,还原的对话将出现在「未分组」中。如需恢复至原工作区,须在 DSH 停止运行时手工编辑 `~/.dsh/storages/workspace.json`。
48
+ - **还原后可能不在原工作区**。插件清除了工作区记账,还原的对话将出现在「未分组」中。如需恢复至原工作区,可在 DSH 停止运行后请 AI 助手协助修改 `~/.dsh/storages/workspace.json`。
56
49
  - **附件不予回收**。DSH 附件按内容寻址、跨会话共享,且无引用计数,插件无法安全判断其可否回收。
57
- - **删除失败时服务端返回的原因文本同样随界面语言切换**:服务端只回错误码与参数,界面文字统一由前端按当前语言取词。
58
50
  - **端点仅接受本机请求**。`/api/session.delete` 仅放行回环地址(`127.0.0.1` / `localhost` / `[::1]`),局域网来源一律返回 403。本插件的路由位于官方鉴权栅栏之外,而官方基于浏览器 cookie 的鉴权依赖插件无法获取的签名密钥;删除数据属高风险操作,故从严处理。将 DSH 绑定至 `0.0.0.0` 并从其他机器访问时,删除将返回 403。
59
51
  - **无法读取会话目录时不作任何改动**。会话树收集失败即整体放弃删除,不会将「读取失败」视为「磁盘上不存在」。
60
52
  - 批量删除浮层不锁定焦点,按 `Tab` 键可能移至浮层后方的页面元素。
package/lib/client.js CHANGED
@@ -94,13 +94,13 @@ window.__ModuleLoader__.load({
94
94
  "err.E_RECYCLE_SPAWN_FAILED": "调用回收站失败:{reason}",
95
95
  "err.E_RECYCLE_NO_RESULT": "回收站未返回结果,脚本未执行完",
96
96
  "err.E_RECYCLE_DISABLED": "本机已关闭回收站(已勾选「不将文件移到回收站中,直接删除」),插件拒绝删除,以免数据无法还原",
97
+ "err.E_NO_RECYCLE_BIN": "磁盘 {volume} 上没有可用的回收站(网络盘、U 盘或该磁盘尚未启用回收站),插件拒绝删除,以免数据无法还原。可先把会话目录搬到本地磁盘,或在该磁盘上删除任意一个文件以启用回收站后再试",
97
98
  "err.E_RECYCLE_UNRECOGNIZED": "无法识别回收站返回的结果",
98
99
  "err.E_RECYCLE_LEFT": "共 {total} 个会话目录,已移入回收站 {moved} 个,仍有 {left} 个未能移入",
99
100
  "err.E_RECYCLE_LEFT_REASON": "共 {total} 个会话目录,已移入回收站 {moved} 个,仍有 {left} 个未能移入:{reason}",
100
101
  "err.E_RECYCLE_MISMATCH": "回收站中仅找到 {matched}/{total} 个会话,可能有目录已被永久删除,请立即检查回收站",
101
102
  "err.E_WS_DETACH_FAILED": "从工作区摘除失败:{reason}",
102
103
  "err.E_DETACH_FAILED": "会话 {id} 未能从工作区摘除:{reason}",
103
- "err.E_ABORTED_NO_CHANGE": "{reason};已放弃删除,未改动任何数据",
104
104
  "err.E_NOT_FOUND": "未找到该会话",
105
105
  "err.E_MOVE_ABORTED": "{reason};已中止,存储域与登记表保持原样",
106
106
  "err.E_LOOPBACK_ONLY": "已拒绝:该接口仅接受本机(回环)请求",
@@ -109,6 +109,7 @@ window.__ModuleLoader__.load({
109
109
  "err.E_BODY_READ_FAILED": "读取请求体失败",
110
110
  "err.E_SESSION_ID_REQUIRED": "缺少会话编号",
111
111
  "err.E_SESSION_ID_INVALID": "会话编号格式不正确:{id}",
112
+ "err.E_AMBIGUOUS_TARGET": "会话编号 {id} 对应多个会话目录({dirs}),无法确定删除哪一个,已拒绝删除,未改动任何数据",
112
113
  "err.E_INTERNAL": "服务端内部错误:{reason}",
113
114
  "leftover.L_SESSION_DIR": "会话目录",
114
115
  "leftover.L_PROJ_CACHE": "投影缓存",
@@ -170,13 +171,13 @@ window.__ModuleLoader__.load({
170
171
  "err.E_RECYCLE_SPAWN_FAILED": "Failed to invoke the recycle bin: {reason}",
171
172
  "err.E_RECYCLE_NO_RESULT": "The recycle bin returned no result; the script did not finish",
172
173
  "err.E_RECYCLE_DISABLED": "The recycle bin is disabled on this machine (\"Don't move files to the Recycle Bin. Remove files immediately when deleted\" is selected), so the plugin refuses to delete rather than leave the data unrestorable",
174
+ "err.E_NO_RECYCLE_BIN": "Volume {volume} has no usable recycle bin (a network drive, a USB stick, or a volume where the recycle bin has not been initialised), so the plugin refuses to delete rather than leave the data unrestorable. Move the session directory to a local disk, or delete any file on that volume once to initialise its recycle bin, and try again",
173
175
  "err.E_RECYCLE_UNRECOGNIZED": "The result returned by the recycle bin could not be recognised",
174
176
  "err.E_RECYCLE_LEFT": "Of {total} session directories, {moved} were moved to the recycle bin and {left} could not be moved",
175
177
  "err.E_RECYCLE_LEFT_REASON": "Of {total} session directories, {moved} were moved to the recycle bin and {left} could not be moved: {reason}",
176
178
  "err.E_RECYCLE_MISMATCH": "Only {matched}/{total} sessions were found in the recycle bin; some directories may have been permanently deleted, so check the recycle bin immediately",
177
179
  "err.E_WS_DETACH_FAILED": "Failed to detach from the workspace: {reason}",
178
180
  "err.E_DETACH_FAILED": "Session {id} could not be detached from the workspace: {reason}",
179
- "err.E_ABORTED_NO_CHANGE": "{reason}; deletion was abandoned and no data was changed",
180
181
  "err.E_NOT_FOUND": "Session not found",
181
182
  "err.E_MOVE_ABORTED": "{reason}; the operation was stopped and no records were deleted",
182
183
  "err.E_LOOPBACK_ONLY": "Refused: this endpoint accepts loopback requests only",
@@ -185,6 +186,7 @@ window.__ModuleLoader__.load({
185
186
  "err.E_BODY_READ_FAILED": "Failed to read the request body",
186
187
  "err.E_SESSION_ID_REQUIRED": "Missing session id",
187
188
  "err.E_SESSION_ID_INVALID": "Invalid session id format: {id}",
189
+ "err.E_AMBIGUOUS_TARGET": "Session id {id} matches more than one session directory ({dirs}), so the target is ambiguous. Deletion was refused and no data was changed",
188
190
  "err.E_INTERNAL": "Internal server error: {reason}",
189
191
  "leftover.L_SESSION_DIR": "session directory",
190
192
  "leftover.L_PROJ_CACHE": "projection cache",
@@ -237,7 +239,7 @@ window.__ModuleLoader__.load({
237
239
 
238
240
  function t(key, params) {
239
241
  var table = DICT[i18n.active] || DICT.zh;
240
- // 词表是普通对象,键可能撞上原型链上的名字(如 constructor),故一律走自有属性判定。
242
+ // 词表为普通对象,键可能与原型链上的属性同名(如 constructor),故一律按自有属性判定。
241
243
  var text = Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;
242
244
  if (text === undefined) {
243
245
  text = Object.prototype.hasOwnProperty.call(DICT.zh, key) ? DICT.zh[key] : undefined;
@@ -257,11 +259,11 @@ window.__ModuleLoader__.load({
257
259
  return Object.prototype.hasOwnProperty.call(table, key) || Object.prototype.hasOwnProperty.call(DICT.zh, key);
258
260
  }
259
261
 
260
- // 服务端的失败是「错误码 + 参数」;嵌套原因(reason)本身也可能是失败对象,故递归展开。
262
+ // 服务端的失败为「错误码 + 参数」;嵌套原因(reason)本身也可能为失败对象,故递归展开。
261
263
  function renderFailure(value) {
262
264
  if (value === null || value === undefined) return "";
263
265
  if (typeof value === "string") {
264
- // 参数里也可能直接嵌一个裸码(而非对象),认得出来就一并取词。
266
+ // 参数中也可能直接嵌入错误码原文(而非对象),可识别时一并取词。
265
267
  var bare = /^(E|L)_[A-Z0-9_]+$/.test(value) ? (value.indexOf("L_") === 0 ? "leftover." : "err.") + value : null;
266
268
  return bare !== null && hasKey(bare) ? t(bare) : value;
267
269
  }
@@ -281,7 +283,7 @@ window.__ModuleLoader__.load({
281
283
  return "";
282
284
  }
283
285
 
284
- // 浮层挂在自己另起的 React 根上(不经官方插槽),故自行订阅语言变化后重渲染。
286
+ // 浮层挂载于自行创建的 React 根上(不经官方插槽),故自行订阅语言变化后重渲染。
285
287
  function useI18nRevision() {
286
288
  var state = react.useState(i18n.revision);
287
289
  var setValue = state[1];
@@ -357,7 +359,7 @@ window.__ModuleLoader__.load({
357
359
  window.alert(renderFailure(result) || t("error.http", { status: response.status }));
358
360
  return;
359
361
  }
360
- // 残留(leftover)与警告都要报出来,只报 warnings 会让「有记录没清掉」静默消失。
362
+ // 残留(leftover)与警告均须报出,仅报 warnings 将使「有记录未清理」静默消失。
361
363
  var notes = collectNotes(result);
362
364
  if (notes.length > 0) {
363
365
  window.alert(t("error.leftover", { list: notes.map(renderFailure).join("\n") }));
@@ -368,7 +370,7 @@ window.__ModuleLoader__.load({
368
370
  }
369
371
  }
370
372
 
371
- // 残留与警告合并成一串:两者都表示「目录已移走,但本地还有东西没清干净」。
373
+ // 残留与警告合并为一串:两者均表示「目录已移走,但本地仍有数据未清理」。
372
374
  function collectNotes(result) {
373
375
  var notes = [];
374
376
  if (result && Array.isArray(result.warnings)) notes = notes.concat(result.warnings);
@@ -428,7 +430,7 @@ window.__ModuleLoader__.load({
428
430
  return value;
429
431
  }
430
432
 
431
- // origin === "subagent" 过滤是安全关键:漏了会把子代理会话列给用户。
433
+ // origin === "subagent" 过滤是安全关键:遗漏会将子代理会话列给用户。
432
434
  function selectBatchRows(listState, workspaceState, currentId) {
433
435
  var rows = [];
434
436
  if (!listState || !listState.byId) return rows;
@@ -486,7 +488,7 @@ window.__ModuleLoader__.load({
486
488
  return undefined;
487
489
  }
488
490
 
489
- // 官方组件(Button/Checkbox/Modal)取不到时有朴素兜底,键盘与语义仍可用。
491
+ // 官方组件(Button/Checkbox/Modal)取不到时有朴素降级实现,键盘与语义仍可用。
490
492
  function uiButton(props) {
491
493
  if (Button) return react.createElement(Button, props);
492
494
  return react.createElement("button", {
@@ -621,7 +623,7 @@ window.__ModuleLoader__.load({
621
623
  setAcknowledged(false);
622
624
  }
623
625
 
624
- // 关闭闸门:删除进行中(busy)时不响应关闭,等跑完。
626
+ // 关闭闸门:删除进行中(busy)时不响应关闭,待其结束。
625
627
  function requestClose() {
626
628
  if (busy) return;
627
629
  onClose();
@@ -741,9 +743,7 @@ window.__ModuleLoader__.load({
741
743
  if (row.workspace) meta.push(react.createElement("span", { key: "ws" }, row.workspace));
742
744
  if (row.fork) meta.push(react.createElement("span", { key: "fork", className: "dsh-session-remover-tag" }, t("tag.fork")));
743
745
  if (row.current) meta.push(react.createElement("span", { key: "cur", className: "dsh-session-remover-tag" }, t("tag.current")));
744
- // label 传元素而非字符串:官方 Checkbox 的运行时是 jsx("span", { children: label }),
745
- // 元素能正常渲染,只有这样才显示得出「标题 + 分支/当前打开」这一整行。
746
- // 官方类型声明写的是 string,将来若给本插件加类型检查会在此报错,属预期差异。
746
+ // label 传元素:官方 Checkbox 运行时按 children 渲染,传字符串将无法显示这一整行。
747
747
  var label = react.createElement("span", { className: "dsh-session-remover-rowmain" }, [
748
748
  react.createElement("span", { key: "t", className: "dsh-session-remover-title" }, row.title),
749
749
  meta.length > 0 ? react.createElement("span", { key: "m", className: "dsh-session-remover-meta" }, meta) : null
@@ -816,7 +816,7 @@ window.__ModuleLoader__.load({
816
816
  });
817
817
  }
818
818
 
819
- // 本插件往官方侧栏插槽注册菜单项;批量浮层是自己另起的 React 根(官方没有多选插槽)。
819
+ // 本插件向官方侧栏插槽注册菜单项;批量浮层为自行创建的 React 根(官方无多选插槽)。
820
820
  var host = { ctx: null, container: null, root: null, seq: 0 };
821
821
 
822
822
  function ensureHost() {
@@ -866,9 +866,7 @@ window.__ModuleLoader__.load({
866
866
  host.container = null;
867
867
  }
868
868
 
869
- // 插槽注册声明了 locale 命名空间,官方框架会把 t 随界面语言变化重新派发;
870
- // 万一没给 t,退回模块内的同一套词表。
871
- // 官方 t 查不到词条时会把 key 原样返回,故必须把「等于 key」也当作没取到,否则菜单会露出裸码。
869
+ // 官方 t 查不到词条时会将 key 原样返回,故「等于 key」同样视为未取到,否则菜单将显示错误码原文。
872
870
  function menuText(props, key) {
873
871
  if (props && typeof props.t === "function") {
874
872
  try {
@@ -949,8 +947,8 @@ window.__ModuleLoader__.load({
949
947
  }
950
948
  host.ctx = ctx;
951
949
 
952
- // locale 服务软取:取不到就维持中文,不阻塞插件启用。
953
- // 注册与订阅各自独立降级——订阅失败不该把已注册成功的词典一并废掉。
950
+ // locale 服务软取:取不到则维持中文,不阻塞插件启用。
951
+ // 注册与订阅各自独立降级——订阅失败不应使已注册成功的词典一并失效。
954
952
  var localeService = null;
955
953
  try { localeService = ctx.get("locale") || ctx.locale || null; } catch (e) { localeService = null; }
956
954
  var localeReady = false;
@@ -972,8 +970,8 @@ window.__ModuleLoader__.load({
972
970
  }
973
971
  }
974
972
  }
975
- // 只有 locale 服务确实就位时才声明 locale:官方在没有 locale face 时渲染该条目会抛错,
976
- // 恒带 locale 会把「软取、拿不到就退回中文」的设计变成整条侧栏报错。
973
+ // 仅当 locale 服务确实就位时才声明 locale:官方在无 locale face 时渲染该条目会抛出异常,
974
+ // 恒带 locale 将使「软取、取不到即退回中文」的设计变成整条侧栏报错。
977
975
  function entryOptions(id, order) {
978
976
  return localeReady
979
977
  ? { name: SLOT, id: id, order: order, locale: NS }
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // dsh-session-remover —— 宿主半(node 侧),浏览器半见同包 lib/client.js。
2
- // POST /api/session.delete body { sessionId } —— 把该对话及其子代理后代移入系统回收站
3
- // 会动用户数据:只放行回环来源,非回环一律拒。
2
+ // POST /api/session.delete body { sessionId } —— 将该对话及其子代理后代移入系统回收站
3
+ // 本插件改动用户数据,故仅放行回环来源,非回环请求一律拒绝。
4
4
  import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, readdirSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
6
6
  import { homedir, tmpdir } from "node:os";
@@ -23,11 +23,11 @@ function json(res, status, value, extraHeaders) {
23
23
  }
24
24
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
25
25
 
26
- // 失败一律回「错误码 + 参数」,由浏览器半按界面语言取词:服务端不产出面向界面的自然语言,
27
- // 否则英文界面下会冒出中文。params 的值可以是字符串,也可以是另一个失败对象,供嵌套原因递归展开。
26
+ // 失败一律返回「错误码 + 参数」,由浏览器半按界面语言取词:服务端不产出面向界面的自然语言,
27
+ // 否则英文界面下将显示中文。params 的值可为字符串,也可为另一个失败对象,供嵌套原因递归展开。
28
28
  const fail = (code, params, extra) => ({ ok: false, code, params: params || {}, ...(extra || {}) });
29
29
 
30
- // 自注册路由在官方 /api 信任栅栏之外,须自补判据。只放行回环来源:官方第二道浏览器 cookie 鉴权依赖插件拿不到的签名密钥。0 = 放行;403 = 拒。
30
+ // 自注册路由位于官方 /api 信任栅栏之外,须自行补充判据。仅放行回环来源:官方第二道浏览器 cookie 鉴权依赖插件无法取得的签名密钥。0 = 放行;403 = 拒绝。
31
31
  function isLoopbackHostname(hostname) {
32
32
  if (hostname === "localhost" || hostname === "[::1]") return true;
33
33
  const parts = String(hostname).split(".");
@@ -74,31 +74,9 @@ function isInside(root, target) {
74
74
  const t = resolve(target).toLowerCase();
75
75
  return t.startsWith(r + "\\") || t.startsWith(r + "/");
76
76
  }
77
- // 读磁盘的函数一律:读失败返回 ok:false。把「读不出来」当成「磁盘上没有」,会误判活会话。
78
- function findSessionDirs(sessionId) {
79
- const root = sessionsRoot();
80
- const variants = idVariants(sessionId);
81
- const out = [];
82
- let slugs = [];
83
- try {
84
- slugs = readdirSync(root, { withFileTypes: true });
85
- } catch (error) {
86
- return { ...fail("E_SESSION_ROOT_UNREADABLE", { reason: String(error?.message || error) }), dirs: out };
87
- }
88
- for (const slug of slugs) {
89
- if (!slug.isDirectory()) continue;
90
- for (const v of variants) {
91
- const p = join(root, slug.name, v);
92
- try {
93
- if (statSync(p).isDirectory() && !out.includes(p)) out.push(p);
94
- } catch {}
95
- }
96
- }
97
- return { ok: true, dirs: out };
98
- }
99
77
 
100
78
  const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
101
- // 复用一块头缓冲:readSessionHeader 是同步函数,不会重入;几百个会话逐个 new 会白扔上百 MB。
79
+ // 复用同一块头缓冲:readSessionHeader 为同步函数,不会重入;数百个会话逐个分配将浪费上百 MB。
102
80
  const HEAD_BUF = Buffer.allocUnsafe(HEAD_BYTES);
103
81
 
104
82
  function sessionLogFiles(dir) {
@@ -134,14 +112,16 @@ function readSessionHeader(dir) {
134
112
  }
135
113
  return null;
136
114
  }
115
+ const normId = (value) => String(value).replace(/^session-/, "");
116
+ const sessionKey = (value) => "session-" + normId(value);
117
+ // 读磁盘的函数一律在读失败时返回 ok:false。将「读不出来」当作「磁盘上没有」会误判活动会话。
137
118
  function indexSessionsOnDisk() {
138
- const byKey = new Map();
139
- const byDir = new Map();
119
+ const entries = [];
140
120
  let slugs = [];
141
121
  try {
142
122
  slugs = readdirSync(sessionsRoot(), { withFileTypes: true });
143
123
  } catch (error) {
144
- return { ...fail("E_SESSION_ROOT_UNREADABLE", { reason: String(error?.message || error) }), byKey, byDir };
124
+ return { ...fail("E_SESSION_ROOT_UNREADABLE", { reason: String(error?.message || error) }), entries };
145
125
  }
146
126
  for (const slug of slugs) {
147
127
  if (!slug.isDirectory()) continue;
@@ -150,17 +130,29 @@ function indexSessionsOnDisk() {
150
130
  try {
151
131
  dirs = readdirSync(slugDir, { withFileTypes: true });
152
132
  } catch (error) {
153
- return { ...fail("E_WORKSPACE_DIR_UNREADABLE", { name: slug.name, reason: String(error?.message || error) }), byKey, byDir };
133
+ return { ...fail("E_WORKSPACE_DIR_UNREADABLE", { name: slug.name, reason: String(error?.message || error) }), entries };
154
134
  }
155
135
  for (const d of dirs) {
156
136
  if (!d.isDirectory()) continue;
157
137
  const dir = join(slugDir, d.name);
158
- const entry = { dir, dirName: d.name, id: "session-" + d.name.replace(/^session-/, ""), header: readSessionHeader(dir) };
159
- byDir.set(d.name, entry);
160
- byKey.set(entry.id, entry);
138
+ const header = readSessionHeader(dir);
139
+ // 编号以 header 为权威:目录名可能是另一种写法,归一化后两者会撞成同一个编号。
140
+ const id = typeof header?.id === "string" && header.id.length > 0 ? header.id : sessionKey(d.name);
141
+ entries.push({ dir, dirName: d.name, id, header });
161
142
  }
162
143
  }
163
- return { ok: true, byKey, byDir };
144
+ return { ok: true, entries };
145
+ }
146
+ // 点名优先:同一编号可能对应多个目录(裸名与带前缀并存),取目录名与请求完全一致的那个;
147
+ // 没有完全一致的才取唯一候选;仍有多个即拒绝,一个字节都不动。
148
+ function resolveSessionRoot(sessionId, entries) {
149
+ const want = normId(sessionId);
150
+ const candidates = entries.filter((e) => normId(e.id) === want);
151
+ if (candidates.length === 0) return { ok: true, entry: null };
152
+ const exact = candidates.filter((e) => e.dirName === sessionId);
153
+ if (exact.length === 1) return { ok: true, entry: exact[0] };
154
+ if (candidates.length === 1) return { ok: true, entry: candidates[0] };
155
+ return fail("E_AMBIGUOUS_TARGET", { id: sessionId, dirs: candidates.map((e) => e.dirName).join(", ") }, { status: 409 });
164
156
  }
165
157
  function permanentSessionKeys(ctx) {
166
158
  const keys = new Set();
@@ -187,53 +179,63 @@ function permanentSessionKeys(ctx) {
187
179
  }
188
180
  return { ok: true, keys };
189
181
  }
190
- // 收集该会话及其磁盘上的全部子代理后代,返回「后代在前、父在后」的目录清单。
182
+ // 收集该会话及其磁盘上的全部子代理后代,返回「后代在前、父在后」的目录与编号清单。
191
183
  function collectSessionTree(ctx, sessionId) {
192
184
  const perm = permanentSessionKeys(ctx);
193
185
  if (!perm.ok) return fail("E_PROTECTED_LIST_UNREADABLE", { reason: perm });
194
186
  const protectedKeys = perm.keys;
195
187
  const index = indexSessionsOnDisk();
196
188
  if (!index.ok) return fail("E_INDEX_UNREADABLE", { reason: index });
197
- const { byKey, byDir } = index;
198
- const rootKey = "session-" + sessionId.replace(/^session-/, "");
189
+ const root = resolveSessionRoot(sessionId, index.entries);
190
+ if (!root.ok) return root;
191
+ if (!root.entry) return { ok: true, dirs: [], ids: [] };
192
+ const dirs = [];
193
+ const ids = [];
194
+ // 空壳目录没有日志,不可能有子代理,只删它自己。
195
+ if (!root.entry.header) {
196
+ dirs.push(root.entry.dir);
197
+ ids.push(root.entry.id);
198
+ return { ok: true, dirs, ids };
199
+ }
199
200
  const childrenOf = new Map();
200
- for (const entry of byKey.values()) {
201
- if (protectedKeys.has(entry.id)) continue;
202
- // 只收 origin === "subagent":fork 出来的分叉会话 header 里也有 parentSession,但它是独立对话,绝不能连带删。
203
- if (entry.header?.origin !== "subagent") continue;
204
- const parent = entry.header?.parentSession;
201
+ for (const entry of index.entries) {
202
+ if (!entry.header) continue;
203
+ // 仅收 origin === "subagent":由 fork 产生的分叉会话 header 中同样有 parentSession,但其属独立对话,不得连带删除。
204
+ if (entry.header.origin !== "subagent") continue;
205
+ const parent = entry.header.parentSession;
205
206
  if (typeof parent !== "string" || parent.length === 0) continue;
206
- const parentKey = "session-" + parent.replace(/^session-/, "");
207
- const list = childrenOf.get(parentKey) ?? [];
208
- list.push(entry.id);
209
- childrenOf.set(parentKey, list);
207
+ if (protectedKeys.has(sessionKey(entry.id))) continue;
208
+ const list = childrenOf.get(parent) ?? [];
209
+ list.push(entry);
210
+ childrenOf.set(parent, list);
210
211
  }
211
- const ordered = [];
212
+ // 先按 header 里的原样编号配对,配不上再试另一种写法,避免两种写法混用时漏掉后代。
213
+ const childrenFor = (entry) => {
214
+ for (const k of [entry.id, normId(entry.id), sessionKey(entry.id)]) {
215
+ const list = childrenOf.get(k);
216
+ if (list && list.length > 0) return list;
217
+ }
218
+ return [];
219
+ };
212
220
  const seen = new Set();
213
- const stack = [rootKey];
221
+ const stack = [[root.entry, false]];
214
222
  while (stack.length > 0) {
215
- const key = stack.pop();
216
- if (seen.has(key)) continue;
217
- seen.add(key);
218
- ordered.push(key);
219
- for (const child of childrenOf.get(key) ?? []) stack.push(child);
220
- }
221
- const targetKeys = ordered.filter((key) => key === rootKey || !protectedKeys.has(key));
222
- const dirs = [];
223
- for (const key of targetKeys.reverse()) {
224
- const entry = byKey.get(key);
225
- if (entry) dirs.push(entry.dir);
226
- }
227
- const rootDirs = new Set(dirs);
228
- for (const name of idVariants(sessionId)) {
229
- const entry = byDir.get(name);
230
- if (entry && !rootDirs.has(entry.dir)) { dirs.push(entry.dir); rootDirs.add(entry.dir); }
223
+ const [entry, expanded] = stack.pop();
224
+ if (expanded) {
225
+ dirs.push(entry.dir);
226
+ ids.push(entry.id);
227
+ continue;
228
+ }
229
+ if (seen.has(entry.dir)) continue;
230
+ seen.add(entry.dir);
231
+ stack.push([entry, true]);
232
+ for (const child of childrenFor(entry)) if (!seen.has(child.dir)) stack.push([child, false]);
231
233
  }
232
- return { ok: true, dirs };
234
+ return { ok: true, dirs, ids };
233
235
  }
234
236
 
235
- // 移入回收站要靠系统脚本宿主 cscript:node 没有回收站接口,PowerShell 每次启动太慢。
236
- // 结果写文件而非 stdout——管道在受限环境里拿不到,写文件到哪都能读。
237
+ // 移入回收站依赖系统脚本宿主 cscript:node 无回收站接口,PowerShell 每次启动开销过大。
238
+ // 结果写入文件而非 stdout——受限环境中无法取得管道输出,写入文件不受此限制。
237
239
  function cscriptPath() {
238
240
  const root = process.env.SystemRoot || "C:\\Windows";
239
241
  for (const p of [join(root, "System32", "cscript.exe"), join(root, "SysWOW64", "cscript.exe")]) {
@@ -241,10 +243,9 @@ function cscriptPath() {
241
243
  }
242
244
  return "";
243
245
  }
244
- // 回收站被关掉(NukeOnDelete=1)时 MoveHere 会直接永久删除,与「可还原」的承诺相反 → 先拒绝。
245
- // 注意:该开关是「按卷」存的(回收站属性按驱动器逐个勾选),根键下通常没有这个值,
246
- // 故必须先由盘符查到卷 GUID,再读该卷的值;只读根键会让这道闸门形同虚设。
247
- // 移完后按「原位置 + 名字」回回收站里点名核对,确认真的进去了,而不是被静默永久删掉。
246
+ // 回收站被关闭(NukeOnDelete=1)时 MoveHere 将直接永久删除,与「可还原」的承诺相反,故先行拒绝。
247
+ // 该开关按卷存储(回收站属性按驱动器逐个勾选),根键下通常没有此值,
248
+ // 故须先由盘符查出卷 GUID,再读该卷的值;仅读根键将使此闸门失效。
248
249
  function recycleScript(paths, resultFile) {
249
250
  return [
250
251
  "var paths = " + JSON.stringify(paths) + ";",
@@ -253,7 +254,7 @@ function recycleScript(paths, resultFile) {
253
254
  'var wsh = new ActiveXObject("WScript.Shell");',
254
255
  "function write(line) { var o = fso.CreateTextFile(resultFile, true, false); o.WriteLine(line); o.Close(); }",
255
256
  "function readNuke(key) { try { return wsh.RegRead(key); } catch (e) { return 0; } }",
256
- // 反斜杠用 fromCharCode 拼,避免多层转义出错。
257
+ // 反斜杠以 fromCharCode 拼接,避免多层转义出错。
257
258
  "var BS = String.fromCharCode(92);",
258
259
  'var ROOT_KEY = ["HKCU","Software","Microsoft","Windows","CurrentVersion","Explorer","BitBucket"].join(BS);',
259
260
  // 盘符 → 卷 GUID:Win32_Volume 的 DeviceID 形如 \\?\Volume{GUID}\
@@ -272,7 +273,7 @@ function recycleScript(paths, resultFile) {
272
273
  " } catch (err) {}",
273
274
  " return \"\";",
274
275
  "}",
275
- // 卷级值读不到时退回根键值(旧版 Windows 把开关放在根键)。
276
+ // 卷级值读不到时退回根键值(旧版 Windows 将该开关置于根键)。
276
277
  "function isNukeOn(p) {",
277
278
  " var volKey = volumeKeyOf(p);",
278
279
  " if (volKey !== \"\") {",
@@ -285,10 +286,8 @@ function recycleScript(paths, resultFile) {
285
286
  "}",
286
287
  'var sh = new ActiveXObject("Shell.Application");',
287
288
  "var bin = sh.NameSpace(10);",
288
- "function baseName(p) { var a = p.split(\"\\\\\"); return a[a.length - 1]; }",
289
- "function dirName(p) { var a = p.split(\"\\\\\"); a.pop(); return a.join(\"\\\\\").toLowerCase(); }",
290
- // 摘内存后落盘句柄的关闭是异步的,一次 MoveHere 可能撞上「目录仍被占用」而失败,
291
- // 故在截止时间内反复重试,只对仍存在的路径再搬一次。
289
+ // 摘内存后落盘句柄的关闭是异步的,一次 MoveHere 可能因「目录仍被占用」而失败,
290
+ // 故在截止时间内反复重试,仅对仍存在的路径再次搬移。
292
291
  "var deadline = (new Date()).getTime() + " + RECYCLE_TIMEOUT_MS + ";",
293
292
  "var left = paths.length;",
294
293
  "var lastErr = \"\";",
@@ -303,53 +302,143 @@ function recycleScript(paths, resultFile) {
303
302
  " if ((new Date()).getTime() > deadline) break;",
304
303
  " WScript.Sleep(200);",
305
304
  "}",
306
- // 逐项容错:回收站里个别项取属性会抛错,一处抛错不能带塌整轮核对。
307
- "var matched = 0;",
308
- "var e = new Enumerator(bin.Items());",
309
- "for (; !e.atEnd(); e.moveNext()) {",
310
- " try {",
311
- " var it = e.item();",
312
- " var nm = String(it.Name);",
313
- " for (var k = 0; k < paths.length; k++) {",
314
- " if (nm !== baseName(paths[k])) continue;",
315
- ' if (String(bin.GetDetailsOf(it, 1)).toLowerCase() === dirName(paths[k])) { matched++; break; }',
316
- " }",
317
- " } catch (err2) {}",
318
- "}",
319
- 'write("RS|" + left + "|" + matched + "|" + lastErr.replace(/[\\r\\n|]/g, " "));',
305
+ 'write("RS|" + left + "|" + lastErr.replace(/[\\r\\n|]/g, " "));',
320
306
  ].join("\r\n");
321
307
  }
308
+ function recycleIndexSnapshot() {
309
+ const seen = new Set();
310
+ for (let code = 65; code <= 90; code++) {
311
+ const root = String.fromCharCode(code) + ":\\$Recycle.Bin";
312
+ let sids;
313
+ try { if (!statSync(root).isDirectory()) continue; sids = readdirSync(root, { withFileTypes: true }); } catch { continue; }
314
+ for (const sid of sids) {
315
+ if (!sid.isDirectory()) continue;
316
+ let names;
317
+ try { names = readdirSync(join(root, sid.name)); } catch { continue; }
318
+ for (const name of names) if (name.startsWith("$I")) seen.add(join(root, sid.name, name));
319
+ }
320
+ }
321
+ return seen;
322
+ }
323
+ // $I 为回收站索引文件,版本 2(Win10 起)布局:0-7 版本、8-15 原大小、16-23 删除时间、
324
+ // 24-27 路径字符数(含结尾 NUL)、28 起为 UTF-16LE 原始路径。
325
+ // 不可用 indexOf(0) 查找结尾:UTF-16LE 中 ASCII 字符为「XX 00」,将在每个字符的高字节处停止。
326
+ function recycleOriginalPath(file) {
327
+ let buf;
328
+ try { buf = readFileSync(file); } catch { return ""; }
329
+ if (buf.length < 28) return "";
330
+ if (buf.readUInt32LE(0) === 1) {
331
+ const end = buf.indexOf(0, 0);
332
+ return buf.subarray(0, end < 0 ? buf.length : end).toString("latin1");
333
+ }
334
+ const start = 28;
335
+ const chars = buf.readUInt32LE(24);
336
+ if (chars > 1 && start + (chars - 1) * 2 <= buf.length) {
337
+ return buf.subarray(start, start + (chars - 1) * 2).toString("utf16le");
338
+ }
339
+ // 长度字段不可信时,退回按 2 字节步进查找结尾 NUL。
340
+ for (let i = start; i + 1 < buf.length; i += 2) {
341
+ if (buf[i] === 0 && buf[i + 1] === 0) return buf.subarray(start, i).toString("utf16le");
342
+ }
343
+ return buf.subarray(start).toString("utf16le");
344
+ }
345
+ function parentDir(p) {
346
+ const parts = String(p).split(/[\\/]/).filter(Boolean);
347
+ parts.pop();
348
+ return parts.join("\\").toLowerCase();
349
+ }
350
+ // 核对是否确实进入回收站:仅认本次新增的 $I 项,按「原位置 + 名字」逐条比对。
351
+ // 采用差集而非全站按名字搜索:同名同路径的旧项可能早已存在于回收站中,将被误判为本次搬移成功。
352
+ function verifyRecycled(before, dirs) {
353
+ const want = dirs.map((d) => ({ name: dirName(d).toLowerCase(), parent: parentDir(d) }));
354
+ // 按目标目录去重计数:同一目录在窗口内出现两条新增 $I 时仅计一次,否则将顶替另一个未搬移成功的目录。
355
+ const hit = new Set();
356
+ let added = 0;
357
+ for (const file of recycleIndexSnapshot()) {
358
+ if (before.has(file)) continue;
359
+ added++;
360
+ const orig = recycleOriginalPath(file);
361
+ if (orig === "") continue;
362
+ const name = dirName(orig).toLowerCase();
363
+ const parent = parentDir(orig);
364
+ for (let i = 0; i < want.length; i++) {
365
+ if (!hit.has(i) && want[i].name === name && want[i].parent === parent) { hit.add(i); break; }
366
+ }
367
+ }
368
+ return { matched: hit.size, added };
369
+ }
370
+ // 判定整批确已进入回收站须同时满足两条:索引中新增的项命中每一个目标,且原路径已从磁盘消失。
371
+ function recycledAll(before, dirs) {
372
+ return verifyRecycled(before, dirs).matched === dirs.length && !dirs.some((d) => existsSync(d));
373
+ }
374
+ // cscript 无回应不等于搬移失败:先查一次索引,全部命中即按成功收尾,否则将误报失败并留下残留记录。
375
+ function salvageMoved(before, dirs) {
376
+ return recycledAll(before, dirs) ? { ok: true, moved: dirs.length, salvaged: true } : null;
377
+ }
378
+ // 回收站按卷生效:网络共享与 FAT/exFAT 盘上 MoveHere 为永久删除,与「可还原」的承诺相反,故先行拒绝。
379
+ function volumeRootOf(dir) {
380
+ const m = /^([A-Za-z]):[\\/]/.exec(resolve(dir));
381
+ return m ? m[1].toUpperCase() + ":\\" : "";
382
+ }
383
+ function recycleBinUsable(dir) {
384
+ const root = volumeRootOf(dir);
385
+ if (root === "") return false;
386
+ try { return statSync(join(root, "$Recycle.Bin")).isDirectory(); } catch { return false; }
387
+ }
322
388
  async function recycleBinMove(dirs) {
323
389
  if (dirs.length === 0) return { ok: true, moved: 0 };
324
390
  const exe = cscriptPath();
325
391
  if (exe === "") return fail("E_CSCRIPT_MISSING");
326
392
  const root = sessionsRoot();
327
393
  if (dirs.some((d) => !isInside(root, d))) return fail("E_DIR_OUTSIDE_ROOT");
394
+ for (const d of dirs) {
395
+ if (!recycleBinUsable(d)) return fail("E_NO_RECYCLE_BIN", { volume: volumeRootOf(d) || d });
396
+ }
328
397
  const work = mkdtempSync(join(tmpdir(), "dsh-session-remover-"));
329
398
  const script = join(work, "recycle.js");
330
399
  const resultFile = join(work, "result.txt");
331
400
  try {
332
- // cscript 按 BOM 判编码,UTF-16LE 才认得出中文路径。
401
+ const before = recycleIndexSnapshot();
402
+ // cscript 按 BOM 判定编码,UTF-16LE 方能识别中文路径。
333
403
  writeFileSync(script, "\uFEFF" + recycleScript(dirs, resultFile), "utf16le");
334
404
  const r = spawnSync(exe, ["//nologo", "//E:JScript", script], {
335
405
  stdio: "ignore", timeout: RECYCLE_TIMEOUT_MS + 30000, windowsHide: true,
336
406
  });
337
- if (r.error) return fail("E_RECYCLE_SPAWN_FAILED", { reason: r.error.message });
407
+ if (r.error) {
408
+ const salvaged = salvageMoved(before, dirs);
409
+ if (salvaged) return salvaged;
410
+ return fail("E_RECYCLE_SPAWN_FAILED", { reason: r.error.message });
411
+ }
338
412
  let text = "";
339
- try { text = readFileSync(resultFile, "utf8"); } catch { return fail("E_RECYCLE_NO_RESULT"); }
413
+ try { text = readFileSync(resultFile, "utf8"); } catch {
414
+ const salvaged = salvageMoved(before, dirs);
415
+ if (salvaged) return salvaged;
416
+ return fail("E_RECYCLE_NO_RESULT");
417
+ }
340
418
  if (text.startsWith("NUKE")) {
341
419
  return fail("E_RECYCLE_DISABLED");
342
420
  }
343
- const m = text.match(/RS\|(\d+)\|(\d+)\|?([^\r\n]*)/);
344
- if (!m) return fail("E_RECYCLE_UNRECOGNIZED");
421
+ const m = text.match(/RS\|(\d+)\|?([^\r\n]*)/);
422
+ if (!m) {
423
+ // 结果文件存在但内容不完整(写入中途进程被终止):同样先查索引再判失败。
424
+ const salvaged = salvageMoved(before, dirs);
425
+ if (salvaged) return salvaged;
426
+ return fail("E_RECYCLE_UNRECOGNIZED");
427
+ }
345
428
  const left = Number(m[1]);
346
- const matched = Number(m[2]);
347
- const lastErr = (m[3] || "").trim();
348
- // 部分成功是可能发生的:搬移是逐项的,失败的项数要如实报出来,不能一律说「未删除任何记录」。
429
+ const lastErr = (m[2] || "").trim();
430
+ // 部分成功是可能发生的:搬移逐项进行,失败项数须如实报出。
349
431
  if (left > 0) {
350
- const params = { left, total: dirs.length, moved: dirs.length - left };
432
+ // 脚本按「原路径是否仍存在」统计 left,句柄释放存在延迟时会误判为仍存在,故再用索引查一次。
433
+ const { matched } = verifyRecycled(before, dirs);
434
+ if (matched === dirs.length && !dirs.some((d) => existsSync(d))) {
435
+ return { ok: true, moved: dirs.length, salvaged: true };
436
+ }
437
+ const moved = Math.min(Math.max(dirs.length - left, matched), dirs.length - 1);
438
+ const params = { left: dirs.length - moved, total: dirs.length, moved };
351
439
  return fail(lastErr === "" ? "E_RECYCLE_LEFT" : "E_RECYCLE_LEFT_REASON", lastErr === "" ? params : { ...params, reason: lastErr });
352
440
  }
441
+ const { matched } = verifyRecycled(before, dirs);
353
442
  if (matched < dirs.length) {
354
443
  return fail("E_RECYCLE_MISMATCH", { matched, total: dirs.length });
355
444
  }
@@ -368,7 +457,7 @@ async function stopAgent(ctx, sessionId) {
368
457
  stopped = true;
369
458
  try { agent.cancel?.({ kind: "disposed" }); } catch {}
370
459
  try {
371
- // Agent 只有 cancel/whenIdle,没有可自己调的 scope;等它静默即可,超时就走人。
460
+ // Agent 仅有 cancel/whenIdle,没有可自行调用的 scope;等待其静默即可,超时则结束等待。
372
461
  if (typeof agent.whenIdle === "function") await Promise.race([agent.whenIdle(), sleep(AGENT_QUIESCE_MS)]);
373
462
  else await sleep(200);
374
463
  } catch {}
@@ -400,7 +489,7 @@ async function detachLive(ctx, sessionId) {
400
489
  const store = sessions.store;
401
490
  const entry = store && typeof store.get === "function" ? store.get(v) : undefined;
402
491
  if (entry === undefined) continue;
403
- // 首选官方 enter() 交出的 detach 闭包:它是唯一会连带摘内部附件的口子。
492
+ // 首选官方 enter() 交出的 detach 闭包:它是唯一会连带摘除内部附件的途径。
404
493
  if (typeof entry.detach === "function") { entry.detach(); detached = true; }
405
494
  else if (typeof sessions.detachEntered === "function") { sessions.detachEntered(entry); detached = true; }
406
495
  else if (typeof store.delete === "function") { store.delete(v); detached = true; }
@@ -449,7 +538,7 @@ async function removeFromArchiveSet(ctx, sessionId) {
449
538
  const registry = ctx.get("workspaceRegistry");
450
539
  if (!registry) return false;
451
540
  const variants = idVariants(sessionId);
452
- // 公开接口优先:不在归档集时它静默返回,正合我用。
541
+ // 公开接口优先:不在归档集时静默返回。
453
542
  if (typeof registry.unarchiveSession === "function") {
454
543
  let removed = false;
455
544
  try {
@@ -479,21 +568,14 @@ async function removeFromArchiveSet(ctx, sessionId) {
479
568
  return removed;
480
569
  }
481
570
 
482
- // 三段式:先摘内存(运行中的会话占着文件句柄,不摘就搬不动)→ 再把整棵树一次性移入回收站
483
- // → 最后清记账。磁盘这一步失败就整体中止,后面的记录一律不动,留给用户重试。
571
+ // 三段式:先摘内存(运行中的会话占用文件句柄,不摘除则无法移动)→ 再将整棵树一次性移入回收站
572
+ // → 最后清理记账。磁盘这一步失败即整体中止,后续记录一律不动,供用户重试。
484
573
  async function deleteSessionCore(ctx, sessionId) {
485
574
  const tree = collectSessionTree(ctx, sessionId);
486
- const treeFailed = tree.ok ? null : tree;
487
- const fallback = tree.ok && tree.dirs.length > 0 ? null : findSessionDirs(sessionId);
488
- if (fallback && !fallback.ok) {
489
- return fail("E_ABORTED_NO_CHANGE", { reason: fallback });
490
- }
491
- const dirs = tree.ok && tree.dirs.length > 0 ? tree.dirs : fallback.dirs;
492
- if (treeFailed) ctx.logger?.warn?.(`[dsh-session-remover] ${sessionId}:连带展开失败 ${treeFailed.code}`);
575
+ if (!tree.ok) return tree;
576
+ const { dirs, ids } = tree;
493
577
  if (dirs.length === 0) return fail("E_NOT_FOUND", {}, { status: 404 });
494
578
 
495
- const ids = dirs.map((d) => "session-" + dirName(d).replace(/^session-/, ""));
496
-
497
579
  let stopped = false, flushed = false, detached = false;
498
580
  for (const id of ids) {
499
581
  stopped = (await stopAgent(ctx, id)) || stopped;
@@ -539,7 +621,7 @@ function auditLeftover(ctx, sessionId, dirs) {
539
621
  if (dirs.some((d) => existsSync(d))) leftover.push({ code: "L_SESSION_DIR" });
540
622
  const sd = ctx.get("storageDomain");
541
623
  if (sd && typeof sd.get === "function") {
542
- // 只审 rc.2 里真实存在的存储域:message_feedback 已改为会话日志权威,不再是存储域。
624
+ // 仅审计 rc.2 中真实存在的存储域:message_feedback 已改为以会话日志为权威,不再是存储域。
543
625
  for (const [domainName, label] of [["session_projcache", "L_PROJ_CACHE"]]) {
544
626
  try {
545
627
  const domain = sd.get(domainName);
@@ -581,7 +663,7 @@ function apply(ctx) {
581
663
  try {
582
664
  for await (const chunk of req) {
583
665
  raw += chunk;
584
- // 超限与「读失败」是两回事:前者是客户端请求过大,回 413 才能让人看懂。
666
+ // 超限与「读失败」性质不同:前者为客户端请求过大,返回 413 便于理解。
585
667
  if (raw.length > BODY_LIMIT) return json(res, 413, fail("E_BODY_TOO_LARGE"));
586
668
  }
587
669
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-remover",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "DSH 插件:在侧栏会话行的「…」菜单中增加「删除对话」与「批量删除…」,将对话及其子代理会话移入 Windows 回收站,可从回收站还原。",
5
5
  "keywords": [
6
6
  "dsh",