dsh-recall-plugin 2.6.2 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
4
 
5
+ ## [2.6.3] - 2026-11-03
6
+
7
+ ### 修复
8
+
9
+ - **Windows 回退解压不再逐条走 `Expand-Archive`(issue #34)**:整树与分批两个恢复分支改走共用的逐条 .NET `ExtractToFile(overwrite: true)`,目录创建按大小写不敏感集合缓存,显式 UTF-8 并 `finally` 释放归档句柄。保留覆盖、CJK 路径、恢复计数、archive 失败可见与 H1 救援语义;issue 报告者的 5000 文件实测为 17.2s → 4.0s,本机冷缓存受 Defender 影响较大,不承诺固定耗时。
10
+ - **Windows 回退支持超过 260 字符的路径**:恢复与删除统一用 `\\?\`(UNC 为 `\\?\UNC\`)扩展绝对路径拼接,解压经 .NET 目录/文件 API 落盘;删除改用 `File.Delete` 并先清只读属性,普通路径与超长路径行为一致。解压失败或目录替换仍会终止并进入 H1 救援,不会假报 `ROLLBACK_OK`。
11
+ - **回退恢复后的文件 mtime 显式刷新**:逐条解压保留的是快照提交时间,与旧 index 的 stat 记录可能碰撞,导致紧接着的快照漏掉恢复内容、后续回退取回错误版本。现在与 POSIX 的 `tar -m` 对齐,把恢复文件 touch 到当前时刻。
12
+ - **大变更预览的分类计数改为全量口径**:此前摘要按截断后的前 500 条清单统计,与总数并列时读起来像清单丢了条目(实测 3054 条变更只报「修改 448 · 恢复 50 · 删除 2」)。现在 Windows 脚本在截断前输出 `COUNTS` 行、POSIX 由 Host 按全量清单统计,确认面板用全量计数渲染摘要;拿不到可信计数时面板隐去摘要,而不是显示一个对不上的数字。
13
+ - **工作区路径过长时给出可行动提示**:新增 `pathTooLong` 环境错误分类,识别 git 的 `Filename too long` 与 PowerShell 拉起 native 命令失败的 `The directory name is invalid`,提示「把项目移到更短的路径」。这是 Windows `MAX_PATH` 的平台限制(工作区根 ≥260 字符时 git 拒绝索引,`\\?\` 只救工作区内部的深文件),已在 README「已知限制」写明。
14
+
15
+ ### 测试
16
+
17
+ - **POSIX 回退补齐真执行回归**:新增 bash + git 冒烟,覆盖分批恢复、CJK/空格路径、`tar -m` 的 mtime 失配和超过 260 字符的路径。POSIX 模板原本已具备这些语义,本次只修正过时注释,不改实现。
18
+ - **长路径用例改为按实际根长度构造**:原先写死目录段数,在 CI 的 `/tmp` 短根上目标路径刚好 260 字符、断言落空(本地 `%TEMP%` 更长才通过)。现在按根长度反推段数,跨 runner 稳定。
19
+
5
20
  ## [2.6.2] - 2026-11-03
6
21
 
7
22
  ### 修复
package/README.en.md CHANGED
@@ -78,6 +78,7 @@ Boundaries accepted by design and edge cases not yet covered — worth checking
78
78
  - Supports Windows (PowerShell 5.1/7 + git CLI) and Linux/macOS (bash + git CLI). Windows is thoroughly verified on real machines; Linux has been fully tested on WSL2 (Ubuntu 26.04, bash 5.3 + git 2.53), including Chinese paths, home fallback, session cleanup, and gc; the macOS side is written to be bash 3.2 compatible but has not been tested on real hardware yet.
79
79
  - Nested git repositories inside the workspace (subdirectories with their own `.git`) cannot be indexed: the snapshot proceeds for everything else (fail-open, with a notice listing the skipped paths), but their contents do not participate in recalls.
80
80
  - Windows reserved device names (such as `nul`, `aux`, `con`, and `com1`, including extension forms) cannot be indexed by Git and are excluded from snapshots and recalls by default. These paths are left untouched and do not generate repeated skip notices. This exclusion applies only to Windows.
81
+ - A **workspace whose root path itself exceeds 260 characters** is not supported: that is Windows' `MAX_PATH` hard limit — git refuses to index such a directory (`\\?\` extended paths only rescue deep files *inside* the workspace, not the root prefix itself; measured failure starting at 259 characters). Snapshots for such a workspace fail, and "Recent errors" tells you to move the project to a shorter path. Deep files inside a workspace (short root, file path over 260) are unaffected and snapshot / roll back normally.
81
82
  - **Directory reparse points** in the workspace (junctions / directory symlinks) are not snapshotted by default: the subtree they point at is not indexed, so its contents do not participate in recalls. This is required on Windows, where git for Windows treats a junction as an ordinary directory and a self-referencing one inflates the index up to Windows' 31-reparse-point path limit (a two-file workspace measured 64 index entries); on POSIX a symlink still enters the snapshot (git records it as a `120000` entry and never recurses). To deliberately snapshot a **benign** junction (pointing outside the workspace, non-self-referencing), append `!path/` (e.g. `!ext-link/`) in the exclusion settings — user rules take effect after machine-generated ones; **never do this for a self-referencing junction** (the explosion vector).
82
83
  - Extreme cases like filenames containing newlines/TAB are beyond the diff list's parsing capability (negligible probability).
83
84
  - **Interplay with dsh-routing-suite (progressive tool-disclosure router)**: if you also run its router-standard preset, a recall forks a new session via `sessions.fork`, which resets the router's stage to its default (the tool surface temporarily narrows). Symptoms, root cause and the fix are documented in [docs/routing-interplay.md](docs/routing-interplay.md) (Chinese).
@@ -210,6 +211,8 @@ The most common questions and their troubleshooting paths; for the full set of d
210
211
 
211
212
  **Can the session's first message be recalled or edited?** Files yes, conversation no — fork needs an earlier turn boundary; "Edit and resend" on the first message is refused outright (the original message is still there, so resending would duplicate it).
212
213
 
214
+ **A recall added a new session to the list — can that be avoided?** No, this is a platform boundary rather than a setting: the only channel DSH offers for "go back to before a message" is `sessions.fork`, and a fork necessarily creates a new session — the client session surface has only fork / open / list / binding, with no verb to delete or truncate a session in place. What the plugin controls is what happens to the **original** session (`archiveOriginal`, on by default: archived to "Archive", recoverable), not whether a new one exists. If by "branch" you meant a git branch: the plugin never creates one — snapshots are `snap-<message ID>` tags only, and your project's own git state is never touched.
215
+
213
216
  ## How It Works
214
217
 
215
218
  When each user message is sent (before the agent touches any files), the workspace is snapshotted into an independent shadow git repository; on recall, a "pre-rollback" safety snapshot is taken first, then files are restored via `git archive` and the conversation is rewound through DSH's official `sessions.fork` mechanism. Binary- and line-ending-safe, and your project's own git state is never touched.
package/README.md CHANGED
@@ -75,6 +75,7 @@
75
75
  - 支持 Windows(PowerShell 5.1/7 + git CLI)与 Linux/macOS(bash + git CLI)。Windows 真机验证充分;Linux 已在 WSL2(Ubuntu 26.04,bash 5.3 + git 2.53)实测全流程(含中文路径、home 降级、会话清理、gc);macOS 侧脚本按 bash 3.2 兼容编写,尚未真机实测。
76
76
  - 工作区内嵌套的其他 git 仓库(子目录自带 `.git`)无法索引:其余部分快照照常(fail-open,页面会提示跳过了哪些路径),但其内容不参与回退。
77
77
  - Windows 保留设备名(如 `nul`、`aux`、`con`、`com1`,含扩展名形式)无法由 Git 索引,默认不参与快照与回退;这些路径保持原样,也不会反复产生跳过提示。此排除仅适用于 Windows。
78
+ - **工作区根路径自身超过 260 字符**时不支持撤回:这是 Windows `MAX_PATH` 的硬限制——git 拒绝索引这类目录(`\\?\` 扩展路径只能救工作区**内部**的深文件,救不了根前缀本身;实测根长 259 字符起就失败)。这种工作区的快照会失败,「最近错误」里提示把项目移到更短的路径。工作区内的深文件(根短、文件路径超 260)不受影响,照常快照与回退。
78
79
  - 工作区内的**目录重解析点**(junction / 目录符号链接)默认不参与快照:它们指向的子树不会被索引,因此那部分内容不参与回退。win32 上这是必需的——git for Windows 把 junction 当普通目录递归,自引用 junction 会让索引膨胀到 Windows 的 31 层重解析点上限(实测 2 个文件的工作区产出 64 条索引);POSIX 侧符号链接照常进快照(git 记成 `120000` 条目,不递归进目标)。确需把某个**良性** junction(指向工作区外、非自引用)纳入快照时,可在排除配置里追加 `!路径/`(如 `!ext-link/`)反选恢复——用户排除行在机器生成行之后生效;**切勿对自引用 junction 使用**(爆炸根因)。
79
80
  - 文件名含换行/TAB 的极端情形超出 diff 清单的解析能力(概率可忽略)。
80
81
  - **与 dsh-routing-suite(渐进式工具披露路由)的交互**:同时启用其 router-standard 预设时,撤回经 `sessions.fork` 出新会话会把路由阶段重置为默认(工具面临时收窄)。现象、成因与解决方案见 [docs/routing-interplay.md](docs/routing-interplay.md)。
@@ -208,6 +209,8 @@ peerDependencies 按 minor 版本线开窗,与 `dsh.compatibility.dshReleases`
208
209
 
209
210
  **会话第一条消息能撤回或编辑吗?** 文件可以回退,对话不能——fork 需要该消息之前的 turn 边界;「编辑并重发」对首条消息会被明确拒绝(原消息还在,补发即成重复消息)。
210
211
 
212
+ **撤回后会话列表多出一个会话,能不能不新建?** 不能,这是平台能力边界而非配置项:DSH 提供的「回到某条消息之前」唯一通道是 `sessions.fork`,fork 必然产生新会话——客户端会话面只有 fork / open / list / binding,没有删除或就地截断会话的动词。插件能控制的是**原会话**的去留(`archiveOriginal`,默认归档到「已归档」、可恢复),不是「是否新建」。若你说的「分支」指的是 git 分支:插件从不创建分支,快照只打 `snap-<消息ID>` tag,不碰项目自身的 git 状态。
213
+
211
214
  ## 工作原理
212
215
 
213
216
  每条用户消息发送时(agent 动文件之前),工作区被快照进一个独立的影子 git 仓库;撤回时先打「回退前」安全快照、再用 `git archive` 恢复文件、通过 DSH 官方 `sessions.fork` 机制把会话切到该消息之前。二进制与换行符安全,全程不触碰项目自身的 git 状态。
package/lib/client.js CHANGED
@@ -2029,9 +2029,12 @@
2029
2029
  if (recall.stage === "confirm") {
2030
2030
  const changes = recall.changes || [];
2031
2031
  const total = typeof recall.total === "number" ? recall.total : changes.length;
2032
- const counts = { modified: 0, restored: 0, added: 0 };
2033
- for (const c of changes) {
2034
- if (c && isChangeKind(c.kind)) counts[c.kind]++;
2032
+ let counts = recall.counts || null;
2033
+ if (!counts && !recall.truncated) {
2034
+ counts = { modified: 0, restored: 0, added: 0 };
2035
+ for (const c of changes) {
2036
+ if (c && isChangeKind(c.kind)) counts[c.kind]++;
2037
+ }
2035
2038
  }
2036
2039
  const rows = changes.map((c, i) => {
2037
2040
  const info = KIND_INFO[c.kind];
@@ -2053,7 +2056,7 @@
2053
2056
  t(sessionOnly ? "recall.truncated.chat" : "recall.truncated.files", { shown: changes.length, total })
2054
2057
  ));
2055
2058
  }
2056
- const summary = summaryText(counts, t);
2059
+ const summary = counts ? summaryText(counts, t) : "";
2057
2060
  const rollbackLead = recall.time ? t("recall.confirm.rollbackAt", { time: clockText2(recall.time) }) : t("recall.confirm.rollbackNoTime");
2058
2061
  const filesLead = t("recall.confirm.files", {
2059
2062
  total,
@@ -2518,6 +2521,8 @@
2518
2521
  treeId: res.treeId || null,
2519
2522
  time: res.time || null,
2520
2523
  cutSeq: typeof res.cutSeq === "number" ? res.cutSeq : null,
2524
+ // 全量分类计数(changes 是截断后的清单,不能拿来统计)
2525
+ counts: res.counts || null,
2521
2526
  // issue #31:仅消息条目没有文件树(老 Host 无该字段 → 按 true)
2522
2527
  files: res.files !== false
2523
2528
  });
@@ -2571,6 +2576,7 @@
2571
2576
  treeId: res2.treeId || null,
2572
2577
  time: res2.time || null,
2573
2578
  cutSeq: typeof res2.cutSeq === "number" ? res2.cutSeq : null,
2579
+ counts: res2.counts || null,
2574
2580
  files: res2.files !== false
2575
2581
  });
2576
2582
  }).catch((error) => {
@@ -1,4 +1,8 @@
1
1
  const ENV_PATTERNS = [
2
+ // pathTooLong 排最前:它既不是 git 缺失也不是权限问题,是 Windows 的
3
+ // MAX_PATH 硬限制(工作区根 ≥260 字符时 git 直接拒绝索引,实测 259 已
4
+ // 报 "must be run in a work tree")。混进其他分类只会把人引去装 git
5
+ ["pathTooLong", [/filename too long/i, /directory name is invalid/i, /path (?:is )?too long/i]],
2
6
  ["git", [/command not found/i, /not recognized/i, /git: not found/i, /is not a git command/i]],
3
7
  ["space", [/no space left on device/i, /disk quota exceeded/i, /enospc/i]],
4
8
  ["permission", [/permission denied/i, /operation not permitted/i, /not permitted/i, /access is denied/i]],
@@ -10,7 +14,10 @@ const ENV_HINTS = {
10
14
  space: "\u78C1\u76D8\u7A7A\u95F4\u5DF2\u6EE1\uFF0C\u5FEB\u7167\u5199\u5165\u5931\u8D25\uFF1A\u6E05\u7406\u78C1\u76D8\u7A7A\u95F4\u540E\u81EA\u52A8\u6062\u590D",
11
15
  permission: "\u5FEB\u7167\u76EE\u5F55\u65E0\u5199\u5165\u6743\u9650\uFF1A\u8BF7\u68C0\u67E5\u76EE\u5F55\u6743\u9650\u540E\u91CD\u8BD5",
12
16
  lock: "\u7591\u4F3C\u591A\u4E2A DSH \u5B9E\u4F8B\u5E76\u53D1\u4F7F\u7528\u540C\u4E00\u5FEB\u7167\u5E93\uFF1A\u8BF7\u786E\u8BA4\u53EA\u542F\u52A8\u4E86\u4E00\u4E2A\uFF1B\u786E\u8BA4\u540E\u4ECD\u5931\u8D25\u65F6\uFF0C\u6309\u300C\u8BBE\u7F6E \xB7 \u63D2\u4EF6\u914D\u7F6E \xB7 \u6700\u8FD1\u9519\u8BEF\u300D\u4E2D\u7684\u8DEF\u5F84\u5220\u9664\u9501\u6587\u4EF6",
13
- mkdir: "\u5FEB\u7167\u5B58\u50A8\u76EE\u5F55\u88AB\u540C\u540D\u6587\u4EF6\u5360\u7528\uFF1A\u5904\u7406\u540E\u81EA\u52A8\u6062\u590D"
17
+ mkdir: "\u5FEB\u7167\u5B58\u50A8\u76EE\u5F55\u88AB\u540C\u540D\u6587\u4EF6\u5360\u7528\uFF1A\u5904\u7406\u540E\u81EA\u52A8\u6062\u590D",
18
+ // 实测边界:Windows 上工作区根 ≥260 字符时 git 拒绝索引(\\?\ 前缀只救
19
+ // 工作区内的深文件,救不了根前缀本身),插件无法绕过——只能请用户挪路径
20
+ pathTooLong: "\u5DE5\u4F5C\u533A\u8DEF\u5F84\u8D85\u8FC7 Windows 260 \u5B57\u7B26\u4E0A\u9650\uFF0Cgit \u65E0\u6CD5\u7D22\u5F15\uFF1A\u8BF7\u628A\u9879\u76EE\u79FB\u5230\u66F4\u77ED\u7684\u8DEF\u5F84\u540E\u91CD\u8BD5"
14
21
  };
15
22
  function classifyEnvError(text) {
16
23
  const s = String(text || "");
@@ -54,7 +54,7 @@ function createRoutesCore(deps) {
54
54
  if (result === null) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
55
55
  const snap2 = state.snapshots.get(id);
56
56
  const cutSeq = await snaps.resolveCutSeq(sessionId, id);
57
- return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, treeId: result.treeId || null, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq, files: snap2 ? snap2.files !== false : void 0 };
57
+ return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, treeId: result.treeId || null, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq, counts: result.counts, files: snap2 ? snap2.files !== false : void 0 };
58
58
  },
59
59
  "execute": async (args) => {
60
60
  const id = args && args.messageId ? String(args.messageId) : "";
@@ -333,8 +333,8 @@ function rollbackScript(root, store, gitExe, tag, base) {
333
333
  // mtime = 解包时刻)。必须如此:tar 默认保留归档内 mtime,而快照→篡改→
334
334
  // 回滚常在数秒内完成,恢复出的 mtime 可能与 index 里旧条目的 stat 记录
335
335
  // 碰撞,下一次 add -A 的 stat 缓存误判「未变更」跳过 re-hash——工作区
336
- // 内容与快照从此脱钩(实测解包出篡改前内容的间歇性失败)。Windows 版的
337
- // Expand-Archive 天然把 mtime 设为解包时刻,无此问题;-m 让 tar 对齐。
336
+ // 内容与快照从此脱钩(实测解包出篡改前内容的间歇性失败)。Windows 逐条
337
+ // 解压也会显式 touch,两侧语义由各自的回退冒烟测试锁定。
338
338
  ' "$git" --git-dir="$g" archive ' + psq(tag) + ' | tar -x -m -C "$root"',
339
339
  " else",
340
340
  " batch=()",
@@ -408,26 +408,56 @@ function diffScript(root, store, gitExe, tag, base, maxChanges) {
408
408
  " $targetMap[$path] = $sha",
409
409
  "}",
410
410
  "$result = @()",
411
+ "$nMod = 0; $nRes = 0; $nAdd = 0",
411
412
  "foreach ($k in $curMap.Keys) {",
412
- ' if (-not $targetMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "added" } }',
413
- ' elseif ($targetMap[$k] -ne $curMap[$k]) { $result += [pscustomobject]@{ rel = $k; kind = "modified" } }',
413
+ ' if (-not $targetMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "added" }; $nAdd++ }',
414
+ ' elseif ($targetMap[$k] -ne $curMap[$k]) { $result += [pscustomobject]@{ rel = $k; kind = "modified" }; $nMod++ }',
414
415
  "}",
415
416
  "foreach ($k in $targetMap.Keys) {",
416
- ' if (-not $curMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "restored" } }',
417
+ ' if (-not $curMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "restored" }; $nRes++ }',
417
418
  "}",
418
419
  "$sorted = @($result | Sort-Object rel)",
420
+ // 分类计数必须在截断前出:面板摘要若按前 N 条统计,大变更时「修改 448」
421
+ // 会与总数 3054 并列展示,读起来像清单丢了条目(实测事故形态)
422
+ "Write-Output ('COUNTS ' + $nMod + ' ' + $nRes + ' ' + $nAdd)",
419
423
  "Write-Output ('TOTAL ' + $sorted.Count)",
420
424
  "Write-Output (ConvertTo-Json -InputObject @($sorted | Select-Object -First " + take + ") -Depth 3 -Compress)",
421
425
  "$tree = (& $git --git-dir=$g --work-tree=$root write-tree).Trim()",
422
426
  "Write-Output ('TREE ' + $tree)"
423
427
  ].join("\n");
424
428
  }
429
+ function expandZipBlock() {
430
+ return [
431
+ "Add-Type -AssemblyName System.IO.Compression.FileSystem",
432
+ "function Expand-ZipTo {",
433
+ " $now = [DateTime]::UtcNow",
434
+ " $made = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)",
435
+ " $za = [System.IO.Compression.ZipFile]::Open($zip, 'Read', [System.Text.Encoding]::UTF8)",
436
+ " try {",
437
+ " foreach ($e in $za.Entries) {",
438
+ " $t = $lpRoot + '\\' + ($e.FullName -replace '/','\\')",
439
+ " if ($e.Name) {",
440
+ " $d = $t.Substring(0, $t.LastIndexOf('\\'))",
441
+ " if ($d -and $made.Add($d)) { [System.IO.Directory]::CreateDirectory($d) | Out-Null }",
442
+ " [System.IO.Compression.ZipFileExtensions]::ExtractToFile($e, $t, $true)",
443
+ " [System.IO.File]::SetLastWriteTimeUtc($t, $now)",
444
+ " }",
445
+ " }",
446
+ " } finally { $za.Dispose() }",
447
+ "}"
448
+ ].join("\n");
449
+ }
425
450
  function rollbackScript(root, store, gitExe, tag, base) {
426
451
  return [
427
452
  "$ErrorActionPreference = 'Stop'",
428
453
  "$git = " + psq(gitExe),
429
454
  "$g = " + psq(store.git),
430
455
  "$root = " + psq(root),
456
+ // PS5.1 的 .NET Framework 不认清单级长路径;\\?\ 前缀绕过 MAX_PATH 且不再
457
+ // 规范化路径,必须先把 / 归一为 \,UNC 再改写为 \\?\UNC\。解压与删除共用。
458
+ "$lpRoot = $root.Replace('/','\\')",
459
+ String.raw`$lpRoot = if ($lpRoot.StartsWith('\\?\')) { $lpRoot } elseif ($lpRoot.StartsWith('\\')) { '\\?\UNC\' + $lpRoot.Substring(2) } else { '\\?\' + $lpRoot }`,
460
+ "$lpRoot = $lpRoot.TrimEnd('\\')",
431
461
  dropGitlinksBlock(),
432
462
  excludeSyncBlock(store.excludeFile, base),
433
463
  // issue #29:同 snapshotScript(回退前的当前清单同样不该为大文件写对象)
@@ -464,23 +494,24 @@ function rollbackScript(root, store, gitExe, tag, base) {
464
494
  "$restored = $restoreRel.Count",
465
495
  "if ($restored -gt 0) {",
466
496
  " $zip = " + psq(store.dir + "\\restore-tmp.zip"),
497
+ expandZipBlock(),
467
498
  " if ($restoreRel.Count * 2 -ge $targetMap.Count) {",
468
499
  " & $git --git-dir=$g archive --format=zip --output=$zip " + psq(tag),
469
500
  " if ($LASTEXITCODE -ne 0) { throw ('git archive failed (exit ' + $LASTEXITCODE + ')') }",
470
- " Expand-Archive -LiteralPath $zip -DestinationPath $root -Force",
501
+ " Expand-ZipTo",
471
502
  " Remove-Item -LiteralPath $zip -Force",
472
503
  " } else {",
473
504
  " for ($i = 0; $i -lt $restoreRel.Count; $i += 100) {",
474
505
  " $batch = $restoreRel.GetRange($i, [Math]::Min(100, $restoreRel.Count - $i))",
475
506
  " & $git --literal-pathspecs --git-dir=$g archive --format=zip --output=$zip " + psq(tag) + " -- $batch",
476
507
  " if ($LASTEXITCODE -ne 0) { throw ('git archive failed (exit ' + $LASTEXITCODE + ')') }",
477
- " Expand-Archive -LiteralPath $zip -DestinationPath $root -Force",
508
+ " Expand-ZipTo",
478
509
  " Remove-Item -LiteralPath $zip -Force",
479
510
  " }",
480
511
  " }",
481
512
  "}",
482
- // 删除失败语义与 POSIX 版对齐(F-G2):本侧 EAP=Stop 下 Remove-Item
483
- // 失败直接抛终止错误、pwsh 以非零码退出;POSIX 侧 rm 在 set -e 的 if
513
+ // 删除失败语义与 POSIX 版对齐(F-G2):本侧 EAP=Stop 下 File.Delete 失败
514
+ // 直接抛终止错误、pwsh 以非零码退出;POSIX 侧 rm 在 set -e 的 if
484
515
  // 条件里失败不会自动终止,须显式 exit 1(见 scripts.posix.js rollbackScript)。
485
516
  // 任何一侧半回退都不许报 ROLLBACK_OK——假成功会让救援永不触发(H1)。
486
517
  "$deleted = 0",
@@ -488,8 +519,12 @@ function rollbackScript(root, store, gitExe, tag, base) {
488
519
  " if (-not $r) { continue }",
489
520
  ' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
490
521
  " if (-not $targetMap.ContainsKey($path)) {",
491
- " $full = Join-Path $root ($path.Replace('/','\\'))",
492
- " if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Force; $deleted++ }",
522
+ " $full = $lpRoot + '\\' + ($path.Replace('/','\\'))",
523
+ " if ([System.IO.File]::Exists($full)) {",
524
+ " $attrs = [System.IO.File]::GetAttributes($full)",
525
+ " if (($attrs -band [System.IO.FileAttributes]::ReadOnly) -ne 0) { [System.IO.File]::SetAttributes($full, [System.IO.FileAttributes]::Normal) }",
526
+ " [System.IO.File]::Delete($full); $deleted++",
527
+ ' } elseif ([System.IO.Directory]::Exists($full)) { throw ("cannot remove directory at tracked file path: " + $path) }',
493
528
  " }",
494
529
  "}",
495
530
  "Write-Output ('ROLLBACK_OK ' + $deleted + ' ' + $restored)"
package/lib/snapshots.js CHANGED
@@ -69,16 +69,38 @@ function parseTreeId(out) {
69
69
  }
70
70
  return null;
71
71
  }
72
+ function countChanges(list) {
73
+ const counts = { modified: 0, restored: 0, added: 0 };
74
+ for (const c of list) {
75
+ if (c && Object.prototype.hasOwnProperty.call(counts, String(c.kind))) counts[c.kind]++;
76
+ }
77
+ return counts;
78
+ }
79
+ function parseCounts(line) {
80
+ const parts = line.trim().split(/\s+/);
81
+ if (parts.length !== 3) return null;
82
+ const m = parseInt(parts[0] ?? "", 10);
83
+ const r = parseInt(parts[1] ?? "", 10);
84
+ const a = parseInt(parts[2] ?? "", 10);
85
+ if (!Number.isFinite(m) || !Number.isFinite(r) || !Number.isFinite(a)) return null;
86
+ if (m < 0 || r < 0 || a < 0) return null;
87
+ return { modified: m, restored: r, added: a };
88
+ }
72
89
  function parseDiffOutput(text, isWin, maxChanges) {
73
90
  const lines = String(text || "").split(/\r?\n/);
74
91
  let total = null;
75
92
  let treeId = null;
93
+ let counts = null;
76
94
  const body = [];
77
95
  for (const line of lines) {
78
96
  if (line.indexOf("TREE ") === 0) {
79
97
  treeId = line.slice(5).trim() || null;
80
98
  continue;
81
99
  }
100
+ if (line.indexOf("COUNTS ") === 0) {
101
+ counts = parseCounts(line.slice(7));
102
+ continue;
103
+ }
82
104
  if (line.indexOf("TOTAL ") === 0) {
83
105
  const n = parseInt(line.slice(6), 10);
84
106
  if (Number.isFinite(n) && n >= 0) total = n;
@@ -90,7 +112,9 @@ function parseDiffOutput(text, isWin, maxChanges) {
90
112
  const all = raw ? parseChanges(raw, isWin) : [];
91
113
  const finalTotal = total !== null ? total : all.length;
92
114
  const changes = all.slice(0, maxChanges);
93
- return { changes, total: finalTotal, truncated: finalTotal > changes.length, treeId };
115
+ const truncated = finalTotal > changes.length;
116
+ const complete = all.length >= finalTotal;
117
+ return { changes, total: finalTotal, truncated, treeId, counts: counts || (complete ? countChanges(all) : null) };
94
118
  }
95
119
  function scanCutSeqDetail(events, messageId) {
96
120
  let anchor = -1;
@@ -540,12 +564,12 @@ function createSnapshots(ctx, rt, config) {
540
564
  async function diffFor(messageId) {
541
565
  const snap = state.snapshots.get(String(messageId));
542
566
  if (!snap) return null;
543
- if (snap.files === false) return { changes: [], total: 0, truncated: false, treeId: null };
567
+ if (snap.files === false) return { changes: [], total: 0, truncated: false, treeId: null, counts: { modified: 0, restored: 0, added: 0 } };
544
568
  const store = state.stores.get(snap.root);
545
569
  if (!store) return null;
546
570
  const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe || "", "snap-" + messageId, BASE(), MAX_CHANGES), { timeoutMs: 6e5, stdoutMaxBytes: 8388608 }));
547
571
  const trimmed = text.trim();
548
- if (!trimmed) return { changes: [], total: 0, truncated: false, treeId: null };
572
+ if (!trimmed) return { changes: [], total: 0, truncated: false, treeId: null, counts: { modified: 0, restored: 0, added: 0 } };
549
573
  return parseDiffOutput(trimmed, rt.isWin, MAX_CHANGES);
550
574
  }
551
575
  async function rollbackFor(messageId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "2.6.2",
3
+ "version": "2.6.3",
4
4
  "description": "在 DSH 中撤回自己发过的消息:工作区文件与对话历史一起回到那条消息发出之前,消息的文本与附件自动放回输入框,改完即可重发。文件走独立影子 git 快照(不碰项目自身的 git),对话走官方 sessions.fork,原会话归档可找回。",
5
5
  "icon": "assets/icon.svg",
6
6
  "type": "module",