dsh-session-remover 1.0.0 → 1.0.1

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
@@ -43,7 +43,7 @@ dsh plugin --profile desktop add dsh-session-remover
43
43
 
44
44
  ## Notes
45
45
 
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.
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 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
47
  - **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.
48
48
  - **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
49
  - **Deleting the currently open conversation is supported.** Its running task is stopped and flushed to disk first, and no DSH restart is required.
@@ -51,6 +51,7 @@ dsh plugin --profile desktop add dsh-session-remover
51
51
  ## Known limitations
52
52
 
53
53
  - **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.
54
+ - **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
55
  - **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
56
  - **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.
56
57
  - **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.
package/README.md CHANGED
@@ -43,7 +43,7 @@ dsh plugin --profile desktop add dsh-session-remover
43
43
 
44
44
  ## 说明
45
45
 
46
- - **删除为移入回收站,并非抹除**:同盘移动,不复制数据,`.dsh` 内不留副本。删除前先按会话所在磁盘核对回收站是否被关闭(Windows 该开关按磁盘逐个设置);已勾选「不将文件移到回收站中,直接删除」时,插件**拒绝删除**,不会退化为永久删除。移入后另按「原位置 + 名字」在回收站中逐项核对,确认确实进入回收站。
46
+ - **删除为移入回收站,并非抹除**:同盘移动,不复制数据,`.dsh` 内不留副本。删除前先按会话所在磁盘核对两件事:该磁盘是否关闭了回收站(Windows 该开关按磁盘逐个设置)、该磁盘是否有可用的回收站;任一不满足即**拒绝删除**,不会退化为永久删除。移入后另按「原位置 + 名字」逐项核对,确认确实进入回收站;核对直接读取本机回收站索引(只列文件名,不逐项读取),耗时基本不受回收站已存项数影响。
47
47
  - **一并删除子代理会话**:子代理会话不显示于侧栏与搜索,未一并清理将持续累积。由 fork 产生的分叉会话属独立对话,**不被连带删除**。
48
48
  - **清理三处记录**:投影缓存、工作区记账、归档集合。投影缓存为派生数据,还原后由 DSH 自动重建;后两处予以清理以避免在本地留存痕迹,代价是还原后的对话归入侧栏的「未分组」。
49
49
  - **支持删除当前打开的对话**:删除前先停止其运行中的任务并落盘,无需重启 DSH。
@@ -51,6 +51,7 @@ dsh plugin --profile desktop add dsh-session-remover
51
51
  ## 已知限制
52
52
 
53
53
  - **仅支持 Windows**。移入回收站依赖系统脚本宿主 `cscript.exe`(Windows 自带);在其他系统上插件拒绝删除并报错,不会退化为永久删除。
54
+ - **所在磁盘没有回收站时拒绝删除**。会话目录若位于网络盘、U 盘或未启用回收站的磁盘(如 FAT/exFAT),移入回收站会退化为永久删除,插件一律拒绝。处理办法:先把会话目录搬到本地磁盘,或在该磁盘上删除任意一个文件以启用回收站,再重试。
54
55
  - **回收站清空后不可还原**。插件仅负责将文件移入回收站,是否清空由用户决定;回收站存在容量上限,超出配额时 Windows 将丢弃最早的项。
55
56
  - **还原后可能不在原工作区**。插件清除了工作区记账,还原的对话将出现在「未分组」中。如需恢复至原工作区,须在 DSH 停止运行时手工编辑 `~/.dsh/storages/workspace.json`。
56
57
  - **附件不予回收**。DSH 附件按内容寻址、跨会话共享,且无引用计数,插件无法安全判断其可否回收。
package/lib/client.js CHANGED
@@ -94,6 +94,7 @@ 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}",
@@ -170,6 +171,7 @@ 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}",
@@ -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,7 +74,7 @@ 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。把「读不出来」当成「磁盘上没有」,会误判活会话。
77
+ // 读磁盘的函数一律在读失败时返回 ok:false。将「读不出来」当作「磁盘上没有」会误判活动会话。
78
78
  function findSessionDirs(sessionId) {
79
79
  const root = sessionsRoot();
80
80
  const variants = idVariants(sessionId);
@@ -98,7 +98,7 @@ function findSessionDirs(sessionId) {
98
98
  }
99
99
 
100
100
  const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
101
- // 复用一块头缓冲:readSessionHeader 是同步函数,不会重入;几百个会话逐个 new 会白扔上百 MB。
101
+ // 复用同一块头缓冲:readSessionHeader 为同步函数,不会重入;数百个会话逐个分配将浪费上百 MB。
102
102
  const HEAD_BUF = Buffer.allocUnsafe(HEAD_BYTES);
103
103
 
104
104
  function sessionLogFiles(dir) {
@@ -199,7 +199,7 @@ function collectSessionTree(ctx, sessionId) {
199
199
  const childrenOf = new Map();
200
200
  for (const entry of byKey.values()) {
201
201
  if (protectedKeys.has(entry.id)) continue;
202
- // 只收 origin === "subagent":fork 出来的分叉会话 header 里也有 parentSession,但它是独立对话,绝不能连带删。
202
+ // 仅收 origin === "subagent":由 fork 产生的分叉会话 header 中同样有 parentSession,但其属独立对话,不得连带删除。
203
203
  if (entry.header?.origin !== "subagent") continue;
204
204
  const parent = entry.header?.parentSession;
205
205
  if (typeof parent !== "string" || parent.length === 0) continue;
@@ -232,8 +232,8 @@ function collectSessionTree(ctx, sessionId) {
232
232
  return { ok: true, dirs };
233
233
  }
234
234
 
235
- // 移入回收站要靠系统脚本宿主 cscript:node 没有回收站接口,PowerShell 每次启动太慢。
236
- // 结果写文件而非 stdout——管道在受限环境里拿不到,写文件到哪都能读。
235
+ // 移入回收站依赖系统脚本宿主 cscript:node 无回收站接口,PowerShell 每次启动开销过大。
236
+ // 结果写入文件而非 stdout——受限环境中无法取得管道输出,写入文件不受此限制。
237
237
  function cscriptPath() {
238
238
  const root = process.env.SystemRoot || "C:\\Windows";
239
239
  for (const p of [join(root, "System32", "cscript.exe"), join(root, "SysWOW64", "cscript.exe")]) {
@@ -241,10 +241,9 @@ function cscriptPath() {
241
241
  }
242
242
  return "";
243
243
  }
244
- // 回收站被关掉(NukeOnDelete=1)时 MoveHere 会直接永久删除,与「可还原」的承诺相反 → 先拒绝。
245
- // 注意:该开关是「按卷」存的(回收站属性按驱动器逐个勾选),根键下通常没有这个值,
246
- // 故必须先由盘符查到卷 GUID,再读该卷的值;只读根键会让这道闸门形同虚设。
247
- // 移完后按「原位置 + 名字」回回收站里点名核对,确认真的进去了,而不是被静默永久删掉。
244
+ // 回收站被关闭(NukeOnDelete=1)时 MoveHere 将直接永久删除,与「可还原」的承诺相反,故先行拒绝。
245
+ // 该开关按卷存储(回收站属性按驱动器逐个勾选),根键下通常没有此值,
246
+ // 故须先由盘符查出卷 GUID,再读该卷的值;仅读根键将使此闸门失效。
248
247
  function recycleScript(paths, resultFile) {
249
248
  return [
250
249
  "var paths = " + JSON.stringify(paths) + ";",
@@ -253,7 +252,7 @@ function recycleScript(paths, resultFile) {
253
252
  'var wsh = new ActiveXObject("WScript.Shell");',
254
253
  "function write(line) { var o = fso.CreateTextFile(resultFile, true, false); o.WriteLine(line); o.Close(); }",
255
254
  "function readNuke(key) { try { return wsh.RegRead(key); } catch (e) { return 0; } }",
256
- // 反斜杠用 fromCharCode 拼,避免多层转义出错。
255
+ // 反斜杠以 fromCharCode 拼接,避免多层转义出错。
257
256
  "var BS = String.fromCharCode(92);",
258
257
  'var ROOT_KEY = ["HKCU","Software","Microsoft","Windows","CurrentVersion","Explorer","BitBucket"].join(BS);',
259
258
  // 盘符 → 卷 GUID:Win32_Volume 的 DeviceID 形如 \\?\Volume{GUID}\
@@ -272,7 +271,7 @@ function recycleScript(paths, resultFile) {
272
271
  " } catch (err) {}",
273
272
  " return \"\";",
274
273
  "}",
275
- // 卷级值读不到时退回根键值(旧版 Windows 把开关放在根键)。
274
+ // 卷级值读不到时退回根键值(旧版 Windows 将该开关置于根键)。
276
275
  "function isNukeOn(p) {",
277
276
  " var volKey = volumeKeyOf(p);",
278
277
  " if (volKey !== \"\") {",
@@ -285,10 +284,8 @@ function recycleScript(paths, resultFile) {
285
284
  "}",
286
285
  'var sh = new ActiveXObject("Shell.Application");',
287
286
  "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
- // 故在截止时间内反复重试,只对仍存在的路径再搬一次。
287
+ // 摘内存后落盘句柄的关闭是异步的,一次 MoveHere 可能因「目录仍被占用」而失败,
288
+ // 故在截止时间内反复重试,仅对仍存在的路径再次搬移。
292
289
  "var deadline = (new Date()).getTime() + " + RECYCLE_TIMEOUT_MS + ";",
293
290
  "var left = paths.length;",
294
291
  "var lastErr = \"\";",
@@ -303,53 +300,143 @@ function recycleScript(paths, resultFile) {
303
300
  " if ((new Date()).getTime() > deadline) break;",
304
301
  " WScript.Sleep(200);",
305
302
  "}",
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, " "));',
303
+ 'write("RS|" + left + "|" + lastErr.replace(/[\\r\\n|]/g, " "));',
320
304
  ].join("\r\n");
321
305
  }
306
+ function recycleIndexSnapshot() {
307
+ const seen = new Set();
308
+ for (let code = 65; code <= 90; code++) {
309
+ const root = String.fromCharCode(code) + ":\\$Recycle.Bin";
310
+ let sids;
311
+ try { if (!statSync(root).isDirectory()) continue; sids = readdirSync(root, { withFileTypes: true }); } catch { continue; }
312
+ for (const sid of sids) {
313
+ if (!sid.isDirectory()) continue;
314
+ let names;
315
+ try { names = readdirSync(join(root, sid.name)); } catch { continue; }
316
+ for (const name of names) if (name.startsWith("$I")) seen.add(join(root, sid.name, name));
317
+ }
318
+ }
319
+ return seen;
320
+ }
321
+ // $I 为回收站索引文件,版本 2(Win10 起)布局:0-7 版本、8-15 原大小、16-23 删除时间、
322
+ // 24-27 路径字符数(含结尾 NUL)、28 起为 UTF-16LE 原始路径。
323
+ // 不可用 indexOf(0) 查找结尾:UTF-16LE 中 ASCII 字符为「XX 00」,将在每个字符的高字节处停止。
324
+ function recycleOriginalPath(file) {
325
+ let buf;
326
+ try { buf = readFileSync(file); } catch { return ""; }
327
+ if (buf.length < 28) return "";
328
+ if (buf.readUInt32LE(0) === 1) {
329
+ const end = buf.indexOf(0, 0);
330
+ return buf.subarray(0, end < 0 ? buf.length : end).toString("latin1");
331
+ }
332
+ const start = 28;
333
+ const chars = buf.readUInt32LE(24);
334
+ if (chars > 1 && start + (chars - 1) * 2 <= buf.length) {
335
+ return buf.subarray(start, start + (chars - 1) * 2).toString("utf16le");
336
+ }
337
+ // 长度字段不可信时,退回按 2 字节步进查找结尾 NUL。
338
+ for (let i = start; i + 1 < buf.length; i += 2) {
339
+ if (buf[i] === 0 && buf[i + 1] === 0) return buf.subarray(start, i).toString("utf16le");
340
+ }
341
+ return buf.subarray(start).toString("utf16le");
342
+ }
343
+ function parentDir(p) {
344
+ const parts = String(p).split(/[\\/]/).filter(Boolean);
345
+ parts.pop();
346
+ return parts.join("\\").toLowerCase();
347
+ }
348
+ // 核对是否确实进入回收站:仅认本次新增的 $I 项,按「原位置 + 名字」逐条比对。
349
+ // 采用差集而非全站按名字搜索:同名同路径的旧项可能早已存在于回收站中,将被误判为本次搬移成功。
350
+ function verifyRecycled(before, dirs) {
351
+ const want = dirs.map((d) => ({ name: dirName(d).toLowerCase(), parent: parentDir(d) }));
352
+ // 按目标目录去重计数:同一目录在窗口内出现两条新增 $I 时仅计一次,否则将顶替另一个未搬移成功的目录。
353
+ const hit = new Set();
354
+ let added = 0;
355
+ for (const file of recycleIndexSnapshot()) {
356
+ if (before.has(file)) continue;
357
+ added++;
358
+ const orig = recycleOriginalPath(file);
359
+ if (orig === "") continue;
360
+ const name = dirName(orig).toLowerCase();
361
+ const parent = parentDir(orig);
362
+ for (let i = 0; i < want.length; i++) {
363
+ if (!hit.has(i) && want[i].name === name && want[i].parent === parent) { hit.add(i); break; }
364
+ }
365
+ }
366
+ return { matched: hit.size, added };
367
+ }
368
+ // 判定整批确已进入回收站须同时满足两条:索引中新增的项命中每一个目标,且原路径已从磁盘消失。
369
+ function recycledAll(before, dirs) {
370
+ return verifyRecycled(before, dirs).matched === dirs.length && !dirs.some((d) => existsSync(d));
371
+ }
372
+ // cscript 无回应不等于搬移失败:先复核索引,全部命中即按成功收尾,否则将误报失败并留下残留记录。
373
+ function salvageMoved(before, dirs) {
374
+ return recycledAll(before, dirs) ? { ok: true, moved: dirs.length, salvaged: true } : null;
375
+ }
376
+ // 回收站按卷生效:网络共享与 FAT/exFAT 盘上 MoveHere 为永久删除,与「可还原」的承诺相反,故先行拒绝。
377
+ function volumeRootOf(dir) {
378
+ const m = /^([A-Za-z]):[\\/]/.exec(resolve(dir));
379
+ return m ? m[1].toUpperCase() + ":\\" : "";
380
+ }
381
+ function recycleBinUsable(dir) {
382
+ const root = volumeRootOf(dir);
383
+ if (root === "") return false;
384
+ try { return statSync(join(root, "$Recycle.Bin")).isDirectory(); } catch { return false; }
385
+ }
322
386
  async function recycleBinMove(dirs) {
323
387
  if (dirs.length === 0) return { ok: true, moved: 0 };
324
388
  const exe = cscriptPath();
325
389
  if (exe === "") return fail("E_CSCRIPT_MISSING");
326
390
  const root = sessionsRoot();
327
391
  if (dirs.some((d) => !isInside(root, d))) return fail("E_DIR_OUTSIDE_ROOT");
392
+ for (const d of dirs) {
393
+ if (!recycleBinUsable(d)) return fail("E_NO_RECYCLE_BIN", { volume: volumeRootOf(d) || d });
394
+ }
328
395
  const work = mkdtempSync(join(tmpdir(), "dsh-session-remover-"));
329
396
  const script = join(work, "recycle.js");
330
397
  const resultFile = join(work, "result.txt");
331
398
  try {
332
- // cscript 按 BOM 判编码,UTF-16LE 才认得出中文路径。
399
+ const before = recycleIndexSnapshot();
400
+ // cscript 按 BOM 判定编码,UTF-16LE 方能识别中文路径。
333
401
  writeFileSync(script, "\uFEFF" + recycleScript(dirs, resultFile), "utf16le");
334
402
  const r = spawnSync(exe, ["//nologo", "//E:JScript", script], {
335
403
  stdio: "ignore", timeout: RECYCLE_TIMEOUT_MS + 30000, windowsHide: true,
336
404
  });
337
- if (r.error) return fail("E_RECYCLE_SPAWN_FAILED", { reason: r.error.message });
405
+ if (r.error) {
406
+ const salvaged = salvageMoved(before, dirs);
407
+ if (salvaged) return salvaged;
408
+ return fail("E_RECYCLE_SPAWN_FAILED", { reason: r.error.message });
409
+ }
338
410
  let text = "";
339
- try { text = readFileSync(resultFile, "utf8"); } catch { return fail("E_RECYCLE_NO_RESULT"); }
411
+ try { text = readFileSync(resultFile, "utf8"); } catch {
412
+ const salvaged = salvageMoved(before, dirs);
413
+ if (salvaged) return salvaged;
414
+ return fail("E_RECYCLE_NO_RESULT");
415
+ }
340
416
  if (text.startsWith("NUKE")) {
341
417
  return fail("E_RECYCLE_DISABLED");
342
418
  }
343
- const m = text.match(/RS\|(\d+)\|(\d+)\|?([^\r\n]*)/);
344
- if (!m) return fail("E_RECYCLE_UNRECOGNIZED");
419
+ const m = text.match(/RS\|(\d+)\|?([^\r\n]*)/);
420
+ if (!m) {
421
+ // 结果文件存在但内容不完整(写入中途进程被终止):同样先复核再判失败。
422
+ const salvaged = salvageMoved(before, dirs);
423
+ if (salvaged) return salvaged;
424
+ return fail("E_RECYCLE_UNRECOGNIZED");
425
+ }
345
426
  const left = Number(m[1]);
346
- const matched = Number(m[2]);
347
- const lastErr = (m[3] || "").trim();
348
- // 部分成功是可能发生的:搬移是逐项的,失败的项数要如实报出来,不能一律说「未删除任何记录」。
427
+ const lastErr = (m[2] || "").trim();
428
+ // 部分成功是可能发生的:搬移逐项进行,失败项数须如实报出。
349
429
  if (left > 0) {
350
- const params = { left, total: dirs.length, moved: dirs.length - left };
430
+ // 脚本按「原路径是否仍存在」统计 left,句柄释放存在延迟时会误判为仍存在,故再用索引复核一次。
431
+ const { matched } = verifyRecycled(before, dirs);
432
+ if (matched === dirs.length && !dirs.some((d) => existsSync(d))) {
433
+ return { ok: true, moved: dirs.length, salvaged: true };
434
+ }
435
+ const moved = Math.min(Math.max(dirs.length - left, matched), dirs.length - 1);
436
+ const params = { left: dirs.length - moved, total: dirs.length, moved };
351
437
  return fail(lastErr === "" ? "E_RECYCLE_LEFT" : "E_RECYCLE_LEFT_REASON", lastErr === "" ? params : { ...params, reason: lastErr });
352
438
  }
439
+ const { matched } = verifyRecycled(before, dirs);
353
440
  if (matched < dirs.length) {
354
441
  return fail("E_RECYCLE_MISMATCH", { matched, total: dirs.length });
355
442
  }
@@ -368,7 +455,7 @@ async function stopAgent(ctx, sessionId) {
368
455
  stopped = true;
369
456
  try { agent.cancel?.({ kind: "disposed" }); } catch {}
370
457
  try {
371
- // Agent 只有 cancel/whenIdle,没有可自己调的 scope;等它静默即可,超时就走人。
458
+ // Agent 仅有 cancel/whenIdle,没有可自行调用的 scope;等待其静默即可,超时则结束等待。
372
459
  if (typeof agent.whenIdle === "function") await Promise.race([agent.whenIdle(), sleep(AGENT_QUIESCE_MS)]);
373
460
  else await sleep(200);
374
461
  } catch {}
@@ -400,7 +487,7 @@ async function detachLive(ctx, sessionId) {
400
487
  const store = sessions.store;
401
488
  const entry = store && typeof store.get === "function" ? store.get(v) : undefined;
402
489
  if (entry === undefined) continue;
403
- // 首选官方 enter() 交出的 detach 闭包:它是唯一会连带摘内部附件的口子。
490
+ // 首选官方 enter() 交出的 detach 闭包:它是唯一会连带摘除内部附件的途径。
404
491
  if (typeof entry.detach === "function") { entry.detach(); detached = true; }
405
492
  else if (typeof sessions.detachEntered === "function") { sessions.detachEntered(entry); detached = true; }
406
493
  else if (typeof store.delete === "function") { store.delete(v); detached = true; }
@@ -449,7 +536,7 @@ async function removeFromArchiveSet(ctx, sessionId) {
449
536
  const registry = ctx.get("workspaceRegistry");
450
537
  if (!registry) return false;
451
538
  const variants = idVariants(sessionId);
452
- // 公开接口优先:不在归档集时它静默返回,正合我用。
539
+ // 公开接口优先:不在归档集时静默返回。
453
540
  if (typeof registry.unarchiveSession === "function") {
454
541
  let removed = false;
455
542
  try {
@@ -479,8 +566,8 @@ async function removeFromArchiveSet(ctx, sessionId) {
479
566
  return removed;
480
567
  }
481
568
 
482
- // 三段式:先摘内存(运行中的会话占着文件句柄,不摘就搬不动)→ 再把整棵树一次性移入回收站
483
- // → 最后清记账。磁盘这一步失败就整体中止,后面的记录一律不动,留给用户重试。
569
+ // 三段式:先摘内存(运行中的会话占用文件句柄,不摘除则无法移动)→ 再将整棵树一次性移入回收站
570
+ // → 最后清理记账。磁盘这一步失败即整体中止,后续记录一律不动,供用户重试。
484
571
  async function deleteSessionCore(ctx, sessionId) {
485
572
  const tree = collectSessionTree(ctx, sessionId);
486
573
  const treeFailed = tree.ok ? null : tree;
@@ -539,7 +626,7 @@ function auditLeftover(ctx, sessionId, dirs) {
539
626
  if (dirs.some((d) => existsSync(d))) leftover.push({ code: "L_SESSION_DIR" });
540
627
  const sd = ctx.get("storageDomain");
541
628
  if (sd && typeof sd.get === "function") {
542
- // 只审 rc.2 里真实存在的存储域:message_feedback 已改为会话日志权威,不再是存储域。
629
+ // 仅审计 rc.2 中真实存在的存储域:message_feedback 已改为以会话日志为权威,不再是存储域。
543
630
  for (const [domainName, label] of [["session_projcache", "L_PROJ_CACHE"]]) {
544
631
  try {
545
632
  const domain = sd.get(domainName);
@@ -581,7 +668,7 @@ function apply(ctx) {
581
668
  try {
582
669
  for await (const chunk of req) {
583
670
  raw += chunk;
584
- // 超限与「读失败」是两回事:前者是客户端请求过大,回 413 才能让人看懂。
671
+ // 超限与「读失败」性质不同:前者为客户端请求过大,返回 413 便于理解。
585
672
  if (raw.length > BODY_LIMIT) return json(res, 413, fail("E_BODY_TOO_LARGE"));
586
673
  }
587
674
  } 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.1",
4
4
  "description": "DSH 插件:在侧栏会话行的「…」菜单中增加「删除对话」与「批量删除…」,将对话及其子代理会话移入 Windows 回收站,可从回收站还原。",
5
5
  "keywords": [
6
6
  "dsh",