dsh-recall-plugin 2.1.0 → 2.2.0

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,37 @@
2
2
 
3
3
  本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
4
 
5
+ ## [2.2.0] - 2026-08-30
6
+
7
+ 性能优化批次([plan-performance.md](docs/plans/completed/plan-performance.md) PF-1〜PF-9 全项,2026-08-29 实施,同日实弹冒烟 9/9 通过)。API 形状与用户可见语义基本不变(PF-6 删除以「所见为准」、PF-1 校验更严两处行为变化见下);单测 227 → 283 例全绿,`verify:host` 装配门禁、`check:dsh` 巡检、client 产物新鲜度全绿;合成基准同口径对比:快照管理首开 -70%、对话中二次打开免等待、同进程二次 init ≈0、单条删除 -21%、每条消息快照 -14%。
8
+
9
+ ### 新增
10
+
11
+ - **预览指纹校验(PF-1,行为变化:校验更严)**:diff/快照脚本输出 `TREE <hash>`(add -A 后 index 树指纹),preview 随清单回传、client 确认时透传——execute 与安全快照指纹比对即知「预览后文件是否变化」,从「条目总数一致」升级为「内容一致」,且一次撤回少跑一整条重复 diff 进程(4 → 3 条重脚本)。老 client 的 `previewTotal` 条目数校验保留为兼容路径。
12
+ - **manage list stale 渐进刷新(PF-6,行为变化:删除以所见为准)**:每条消息快照不再清空列表缓存而是标 stale——对话中打开快照管理立即以旧列表应答(带 `stale` 字段),后台 dump 补新(in-flight 去重),client 静默二段刷新一次;批量删除在缓存非空时以「用户当前所见」的列表为准构造删除范围。
13
+
14
+ ### 变更(性能等价重构,API 形状不变)
15
+
16
+ - **win32 文本写入改 stdin 单进程(PF-2)**:index.json/lineage.json/exclude.txt/root.txt 落盘从 base64 20000 字符分块(每块一条 PowerShell 进程,索引几百条时 saveIndex 6+ 条)改为 stdin 传全文 + 单进程;POSIX 的内联 cat 一并收进模板同名导出。读取手法由运行时探针钉死(`[Console]::OpenStandardInput()` 字节流——`Console.In` 在 PS 5.1 按输入代码页 GBK 解码 UTF-8 stdin 必挂,且本机 dsh 执行器实际解析到 PS 5.1:pwsh 别名 appexeclink 在 lstat 视角不存在)。
17
+ - **全量枚举换 .NET 手动栈遍历(PF-3)**:超大文件剔除(snapshot/diff/rollback 三脚本各一次,一次撤回共 4 次)与磁盘占用统计改 `Stack[string]` + 逐目录 `EnumerateFiles` + try/catch——.NET 4.x 的 `AllDirectories` 遇 ACL 异常目录中断整个枚举,手动栈才能与 `SilentlyContinue` 逐项容错对齐;几万文件的工作区从数秒级降亚秒。usage 端点多 store 并行(runLimited 4)+ 30s TTL 缓存(删除/gc 后失效)。
18
+ - **lineage 并入 storesDump(PF-4)**:`==DIR` 段内新增 `LINEAGEBEGIN/原文/LINEAGEEND`(与 INDEX 段同构,解析容错),manage lineage 从「每 root 串行一条进程(20 工作区 ≈ 10s)」降为零新增进程。
19
+ - **rebuildOrphans 四档守卫(PF-5)**:索引终态分级(healthy / empty / quarantined / truncated)——healthy 且非空、truncated 时整体跳过重建(顺带根治现有隐患:读截断后残缺内存视图会被无条件 rebuild 用孤儿集覆盖完好大索引);`cleanupLegacy` 加内存标记(同 root 多次 init 只付一条进程)。init/预热常态每 root 省 1+N 条进程,同进程二次 init 零进程。
20
+ - **sweep 换 listSessions(PF-7)**:gc 前的已删会话扫描从逐会话 `readSession` 全日志解压(串行队列内,会话多时堵住快照/撤回)改为一次 `listSessions()` 目录枚举建 id 集合(I8:记录 id 在 header.id);判定更保守(日志损坏但文件在的保留,purge 不可逆宁可少清)。titles 冷读维持现状——探针确认 `SessionHeader` 无 `title` 字段,titles 半项废弃(负向探针钉住,官方未来加 title 时提示可重启该优化)。
21
+ - **exclude-get 探测链合并(PF-8)**:全部 exclude 文件一条脚本 base64 读取(任意用户文本免疫定界混淆),首开进程链 4-6 条 → 2 条。
22
+ - **快照脚本瘦身(PF-9)**:exclude 同步条件化(内容未变跳过重写与清理循环,每条消息常态省 1 次 git 子进程 + 1 次盘写,改排除即时生效不变);update-index 逐条调用合批(pwsh 100 条/批、POSIX xargs -0 自适应),大排除/多超大文件场景子进程 N → N/100。
23
+
24
+ ### 修复
25
+
26
+ - **后台刷新与 dump 失败留痕**:`refreshListCacheInBackground` 与 `dumpStores` 失败原先被 silent catch 完全吞掉(实弹冒烟中曾出现一次约 29 分钟列表 stale 未自愈且零日志可查),现补 console 留痕——复发时看「recall list refresh failed」/「recall stores dump failed」即可定位。
27
+
28
+ ## [2.1.1] - 2026-08-29
29
+
30
+ issue #12 换行符字节保真修复(patch)。单测 227 项全绿,双平台真实模板端到端复验(pwsh 全新/存量迁移 + POSIX 实弹)31 项全过,`check:dsh` 巡检一致。
31
+
32
+ ### 修复
33
+
34
+ - **快照/回退换行符失真(issue #12)**:影子仓库固化为字节保真语义——`info/attributes`(`FIDELITY_ATTRS`)对全部路径关闭 EOL 转换、clean filter、`$Id$` 展开、`export-ignore`/`export-subst`、`working-tree-encoding`。根因是 `git archive`(回退恢复路径)与 `git add`(捕获路径)都会应用快照树里项目自己的 `.gitattributes`:`text=auto` + Windows 缺省 `core.eol=native` 会把 LF 转 CRLF,仓库级 `core.autocrlf=false` 挡不住(属性驱动的转换看 `core.eol`)。存量归一化索引经一次性 `git add --renormalize -- ':(top)'` 迁移(标记文件 `attrs-v1.stamp` 防重复,迁移失败不阻塞快照)。连带修复 `export-ignore` 声明让文件从回退归档中静默消失的同类缺口。注意:回退到本版之前的旧快照仍会还原归一化内容(旧 blob 信息已物理丢失),本版起的新快照字节保真。
35
+
5
36
  ## [2.1.0] - 2026-08-29
6
37
 
7
38
  改进专项与审查修复、环境诊断批次(错误治理 / POSIX home 三档 / 并发治理)、双平台实弹冒烟(Windows + WSL2 Ubuntu 26.04)后发版。单测 224 项、官方 API 探针、`verify:host`、`check:dsh` 全绿。
package/README.en.md CHANGED
@@ -1,19 +1,23 @@
1
1
  # dsh-recall-plugin
2
2
 
3
- > Recall a message, and your project files go back with it.
4
-
5
3
  [简体中文](README.md) | English
6
4
 
7
5
  ![npm](https://img.shields.io/npm/v/dsh-recall-plugin?label=npm&color=cb3837)
8
6
  ![License](https://img.shields.io/badge/license-MIT-blue)
9
7
  ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-blue)
10
- ![DSH](https://img.shields.io/badge/DSH-0.1.0--rc-blue)
11
8
  ![Build](https://img.shields.io/badge/pure%20JS-green)
12
9
 
10
+ ![DSH](https://img.shields.io/badge/DSH-0.1.1--rc.2-blue)
11
+ ![DSH](https://img.shields.io/badge/DSH-0.1.1--rc.1-blue)
12
+ ![DSH](https://img.shields.io/badge/DSH-Desktop-blue)
13
+ ---
14
+ **Under any message you've sent**, **click "↶ Recall"**, **and both your workspace files and the conversation history roll back to the moment right before that message was sent** (DSH 0.1.1-rc.2).
13
15
  ---
14
- **Under any message you've sent**, **click "↶ Recall"**, **and both your workspace files and the conversation history roll back to the moment right before that message was sent**.
16
+
17
+ [Changelog](CHANGELOG.md)
15
18
 
16
19
  ## UI Preview
20
+
17
21
  - Recall button location
18
22
 
19
23
  ![Recall button appears on hover](docs/screenshots/recall-button.png)
@@ -24,34 +28,32 @@
24
28
  | ![Confirmation panel · file change list](docs/screenshots/confirm-panel-1.png) | ![Confirmation panel · rollback scope](docs/screenshots/confirm-panel-2.png) |
25
29
 
26
30
  - After a recall, the message text is auto-refilled into the input box for quick editing and resending (can be disabled in the settings card)
27
- - Settings · plugin config card (thresholds / exclusions / snapshot manager, saved changes apply live)
31
+ - Settings · plugin config card (config form / exclusions / snapshot manager, saved changes apply live)
28
32
 
29
- | Settings | Settings |
30
- | --- | --- |
31
- | ![Settings·editing](docs/screenshots/settings-exclude-1.png) | ![Settings·saved](docs/screenshots/settings-exclude-2.png) |
32
-
33
- - Settings page · Snapshot Manager (tree: workspace → session → snapshot, leaves show message content)
34
-
35
- > Tree-view snapshot manager screenshot to be added later (the feature is already implemented in the current version).
33
+ ![Settings](docs/screenshots/settings-exclude-2.png)
36
34
 
37
35
  ## Highlights
38
36
 
39
37
  - **Files + conversation, rolled back together**: recalling isn't just about chat history — files the agent modified go back to their original state too.
40
38
  - **Never touches your project's own git**: snapshots live in an independent shadow git repository; your branches, staging area, and uncommitted changes are untouched. `.git` and `node_modules` are excluded automatically.
41
39
  - **Keeps your project directory clean**: snapshots always live under `$DSH_HOME`, nothing is ever dropped into your project — regardless of the session's sandbox permission (workspace-write / read-only sessions snapshot and recall as usual). Only when home itself is unwritable (e.g. pointed at a read-only drive) does it fall back to an in-project `.dsh-recall-snapshots` directory (the page shows a notice when degraded); once home is writable again, data migrates back and the fallback directory is cleaned up.
42
- - **Change your mind as many times as you like**: as long as the session still exists (including archived ones), snapshots are fully retained and never pruned. After one recall you can recall again to an even earlier point; files overwritten during a recall always remain recoverable. Once a session is permanently deleted, its snapshots are cleaned up accordingly (see below).
40
+ - **Byte-level fidelity** (2.1.1+): snapshots and recalls are immune to your project's `.gitattributes` EOL conversion LF/CRLF line endings, `$Id$` keywords, and binary content round-trip byte-for-byte (the shadow repository pins `info/attributes` to disable all attribute-driven conversion).
41
+ - **Change your mind as many times as you like**: after one recall you can recall again to an even earlier point; files overwritten during a recall always remain recoverable. Up to 500 snapshots per workspace are kept by default (oldest pruned beyond the cap — adjustable or disableable); once a session is permanently deleted, its snapshots are cleaned up accordingly.
43
42
  - **See the list before you act**: clicking recall first shows the list of files that will change (modified / restored / deleted); nothing is overwritten until you confirm.
43
+ - **Busy-agent guard** (2.0+): preview and recall are refused while an agent is running in the target workspace, so files can't change under you mid-confirmation; if a new snapshot has appeared since the preview, execution forces a fresh preview (staleness check).
44
+ - **Auto-rescue on rollback failure** (2.1+): a "pre-rollback" safety snapshot is taken before every recall; if the rollback fails midway, the workspace is automatically restored to its pre-rollback state — and if the rescue itself fails, you get a copy-paste-ready manual recovery command. No path ever leaves a half-rolled-back workspace behind.
44
45
  - **Disk-friendly**: snapshots use git delta compression — incremental, not full-directory copies. Files larger than 100MB are skipped automatically (the threshold is configurable in the settings card).
45
- - **Automatic housekeeping**: periodic `git gc` packs loose objects (lossless — not a single snapshot is lost); snapshots of deleted sessions are cleaned up automatically; build artifacts can be excluded globally via `exclude.txt` (see below).
46
- - **Failures speak up** (1.7.0+): snapshot failures, skipped paths, and circuit-breaker pauses all surface as a toast at the top of the page (the same fault only bothers you once per 10 minutes) — nothing fails silently; the failure reason lands in the "Recent errors" section of the settings card.
47
- - **Self-healing on failure** (1.7.0+): after a snapshot fails, leftover objects are pruned automatically; 3 consecutive failures trigger an exponential-backoff circuit breaker (auto-retry after the cooldown); the failure path also sweeps stray git processes and stale locks — the disk never bloats from failed retries, and a single hiccup can't wedge the pipeline.
48
- - **Unindexable paths are skipped, not fatal** (1.7.0+): embedded git repositories, unreadable files, and other paths that can't be indexed no longer fail the whole snapshot — the snapshot is still taken, and skipped paths are reported via toast (on recall they are neither restored nor deleted, same semantics as exclusions).
49
- - **Tree-view snapshot manager**: the "Snapshot Manager" on the settings page shows a **workspace → session → snapshot** three-level tree with expand/collapse support; each level has a delete button on its right, so you can clear all snapshots of a workspace or a session at once. Leaves show a summary of the message content the snapshot corresponds to, making it easy to locate "what this message changed back then".
46
+ - **Automatic housekeeping**: periodic `git gc` packs loose objects (lossless — not a single snapshot is lost); snapshots of deleted sessions are cleaned up automatically; optional cleanup by snapshot cap and by retention age (both configurable); build artifacts can be excluded globally via `exclude.txt` (see below).
47
+ - **Failures speak up**: snapshot failures are classified by root cause (git missing / disk full / permission denied / lock conflict / directory conflict) with actionable guidance, surfaced as a toast at the top of the page (the same fault only bothers you once per 10 minutes, adjacent repeats merged with a count) — nothing fails silently; the failure reason lands in the "Recent errors" section of the settings card.
48
+ - **Self-healing on failure**: after a snapshot fails, leftover objects are pruned automatically; 3 consecutive failures trigger an exponential-backoff circuit breaker (auto-retry after the cooldown); the failure path also sweeps stray git processes and stale locks — concurrent instances yield to each other via heartbeats instead of wedging each other (2.1+), and the disk never bloats from failed retries.
49
+ - **Unindexable paths are skipped, not fatal**: embedded git repositories, unreadable files, and other paths that can't be indexed no longer fail the whole snapshot — the snapshot is still taken, and skipped paths are reported via toast (on recall they are neither restored nor deleted, same semantics as exclusions).
50
+ - **Tree-view snapshot manager**: the "Snapshot Manager" on the settings page shows a **workspace → session → snapshot** three-level tree with expand/collapse and search support; sessions produced by recalls are grouped into "version families" (v1/v2/v3) along the fork chain. Each level has a delete button on its right, so you can clear all snapshots of a workspace or a session at once. Leaves show a summary of the message content the snapshot corresponds to, making it easy to locate "what this message changed back then".
50
51
 
51
52
  ## Known Limitations
52
53
 
53
54
  - Snapshots are created **when a message is sent**; messages from before the plugin was enabled have no snapshot and show no recall button.
54
55
  - The first user message of a session cannot roll back the conversation (files only), because fork requires an earlier turn boundary.
56
+ - Recall cannot be initiated while an agent is running in the target workspace (by design — stop the agent first).
55
57
  - 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.
56
58
  - Nested git repositories inside the workspace (subdirectories with their own `.git`) cannot be indexed: the snapshot proceeds for everything else (fail-open, with a toast listing the skipped paths), but their contents do not participate in recalls.
57
59
  - Extreme cases like filenames containing newlines/TAB are beyond the diff list's parsing capability (negligible probability).
@@ -59,7 +61,7 @@
59
61
 
60
62
  ## Installation
61
63
 
62
- Prerequisites: git CLI (without it the recall button won't appear and a notice shows at the top of the page — DSH itself keeps running); PowerShell 5.1 / 7 on Windows, bash + git on Linux/macOS; DSH 0.1.0-rc.x (see `peerDependencies` for dependency versions).
64
+ Prerequisites: git CLI (without it the recall button won't appear and a notice shows at the top of the page — DSH itself keeps running); PowerShell 5.1 / 7 on Windows, bash + git on Linux/macOS; DSH 0.1.1-rc.x (see `peerDependencies` for dependency versions).
63
65
 
64
66
  - Official DSH plugin command: install and auto-mount into the web profile
65
67
  ```powershell
@@ -81,17 +83,36 @@ pm2 restart <your-dsh-name> # if managed by pm2
81
83
 
82
84
  ## Usage
83
85
 
84
- 1. Hover over any user message sent **after the plugin was enabled** — "↶ Recall" appears to the left of the copy button.
86
+ 1. Hover over any user message sent **after the plugin was enabled** (including steering messages inserted while the agent is running) — "↶ Recall" appears to the left of the copy button.
85
87
  2. Click it → the confirmation panel shows the list of files that will change (modified / restored / deleted).
86
88
  3. Click "Confirm rollback" → files are restored to their state before that message was sent; the view switches to a new session (that message and everything after it is removed), while the original session is archived and can be recovered anytime.
87
89
 
90
+ ## Configuration
91
+
92
+ All options can be edited visually in the "**Settings → Plugin Config → Recall Plugin**" card (saved changes apply live, no restart needed), or by restating the insert line under `id: recall` in the profile's `cordis.patch.yml`. Environment variables only override the two gc options and take top priority (fields locked by env are marked and uneditable in the card).
93
+
94
+ | Option | Default | Description |
95
+ | --- | --- | --- |
96
+ | `gcSnaps` | 50 | Run `git gc` after this many snapshots accumulate (env `DSH_RECALL_GC_SNAPS` force-overrides) |
97
+ | `gcHours` | 24 | Run gc when this many hours have passed since the last one (whichever trigger fires first; env `DSH_RECALL_GC_HOURS`) |
98
+ | `maxFileBytes` | 104857600 (100MB) | Files larger than this are neither snapshotted nor touched by recalls |
99
+ | `maxSnapshotsPerWorkspace` | 500 | Maximum snapshots kept per workspace; oldest pruned beyond the cap. 0 = unlimited |
100
+ | `retentionDays` | 0 | Keep snapshots for this many days; older ones are deleted. 0 = disabled (works independently of the cap) |
101
+ | `baseExcludes` | `.git`, `node_modules/`, `.dsh-recall-snapshots/`, `dsh-recall-snapshots/` | Base exclusion list (gitignore syntax, lower priority than exclude.txt) |
102
+ | `refillDraft` | true | Refill the recalled message text into the input box after a recall |
103
+ | `snapshotEnabled` | true | Master snapshot switch (off = no new snapshots; existing snapshots remain recallable) |
104
+ | `archiveOriginal` | true | Archive the original session after a recall (off = the original session stays in the session list) |
105
+
106
+ The settings card also offers "Restore defaults" (one-click reset of all fields) and a "Recent errors" viewer/clearer.
107
+
88
108
  ## Snapshot Maintenance & Cleanup
89
109
 
90
- Snapshots are fully retained as long as "the session might still be recoverable"; on top of that, the plugin manages disk usage automatically — no manual housekeeping needed:
110
+ The plugin manages disk usage automatically — no manual housekeeping needed:
91
111
 
92
- - **Periodic gc**: every 50 snapshots or 24 hours since the last gc (whichever comes first), `git gc` runs in the background to pack loose objects. This is lossless — every snapshot remains recallable. The throttle token lives in `gc.stamp` inside the shadow repository, so restarting DSH does not reset the cycle. Both thresholds can be overridden via environment variables (rarely needed): `DSH_RECALL_GC_SNAPS`, `DSH_RECALL_GC_HOURS`.
112
+ - **Periodic gc**: every 50 snapshots or 24 hours since the last gc (whichever comes first, thresholds configurable), `git gc` runs in the background to pack loose objects. This is lossless — every snapshot remains recallable. The throttle token lives in `gc.stamp` inside the shadow repository, so restarting DSH does not reset the cycle.
113
+ - **Cap & retention**: up to 500 snapshots per workspace by default (oldest pruned beyond the cap); optionally set `retentionDays` for age-based retention. The two triggers work independently and can both be adjusted or disabled in the config card.
93
114
  - **Session-deletion cleanup**: once a session is permanently deleted (its log gone from disk), the next maintenance pass automatically removes all of its snapshots and frees the space. **Archiving is not deletion** — logs of sessions archived by the recall feature itself still exist, so their snapshots are kept and recoverable from the archive. The check is conservative: a session that is merely cold (not in memory) is never cleaned, and when the log's state cannot be verified, it is left alone.
94
- - **User-defined exclusions**: open "**Settings → Plugins → Recall Plugin** card (collapsed by default; click the header to expand)" to edit snapshot exclusions visually — type a path or pattern and press Enter to add it, one-click append for common patterns (`dist/`, `*.log`, `.env`, …), and saved changes take effect on the very next snapshot/recall, no restart needed. Alternatively, edit `dsh-recall-snapshots/exclude.txt` under home directly (i.e. `$DSH_HOME/dsh-recall-snapshots/exclude.txt`, or `~/.dsh/dsh-recall-snapshots/exclude.txt` when unset; UTF-8; one gitignore-style pattern per line; lines starting with `#` are comments) — both paths edit the same configuration, for example:
115
+ - **User-defined exclusions**: open the "**Settings → Plugin Config → Recall Plugin**" card (collapsed by default; click the header to expand) to edit snapshot exclusions visually — type a path or pattern and press Enter to add it, one-click append for common patterns (`dist/`, `*.log`, `.env`, …), and saved changes take effect on the very next snapshot/recall, no restart needed. Alternatively, edit `dsh-recall-snapshots/exclude.txt` under home directly (i.e. `$DSH_HOME/dsh-recall-snapshots/exclude.txt`, or `~/.dsh/dsh-recall-snapshots/exclude.txt` when unset; UTF-8; one gitignore-style pattern per line; lines starting with `#` are comments) — both paths edit the same configuration, for example:
95
116
 
96
117
  ```gitignore
97
118
  # keep build artifacts out of snapshots
@@ -100,14 +121,14 @@ Snapshots are fully retained as long as "the session might still be recoverable"
100
121
  *.log
101
122
  ```
102
123
 
103
- This applies to all projects (when home is unwritable and a workspace falls back to in-project storage, it gets its own independent exclusion config, listed as a separate card in the settings tab). New exclusions only affect future snapshots; **when recalling to an earlier snapshot, files that weren't excluded at that time are still restored** (returning to the state as it was — that's exactly what recall means). To fully purge a directory that already made it into snapshots, manually delete the corresponding hash directory under `dsh-recall-snapshots/` in home. The settings card requires DSH's built-in settings page (all 0.1.0-rc.x releases have it); on very old versions without the tab, editing the file directly is equivalent.
104
- - **Tree-view snapshot manager**: open "**Settings → Plugins → Recall Plugin → Snapshot Manager**" to see the tree list — first level workspace (folder name), second level session (session title), third level snapshot (time + message content summary, hover to see the full content). Workspace and session nodes support expand/collapse; every level has a delete button on its right, with an inline confirmation before deletion. Deleting a workspace = clearing all snapshots of that workspace; deleting a session = clearing all snapshots of that session within that workspace; deleting a leaf = removing just that single snapshot.
124
+ This applies to all projects (when home is unwritable and a workspace falls back to in-project storage, it gets its own independent exclusion config, listed as a separate card in the settings tab). New exclusions only affect future snapshots; **when recalling to an earlier snapshot, files that weren't excluded at that time are still restored** (returning to the state as it was — that's exactly what recall means). To fully purge a directory that already made it into snapshots, manually delete the corresponding hash directory under `dsh-recall-snapshots/` in home.
125
+ - **Tree-view snapshot manager**: open "**Settings → Plugin Config → Recall Plugin → Snapshot Manager**" to see the tree list — first level workspace (folder name), second level session (session title, recall chains grouped into version families), third level snapshot (time + message content summary, hover for the full content). Search and "load more" are supported; workspace and session nodes expand/collapse; every level has a delete button on its right, with a confirmation before deletion. Deleting a workspace = clearing all snapshots of that workspace; deleting a session = clearing all snapshots of that session within that workspace; deleting a leaf = removing just that single snapshot; a confirmed "Delete all" button sits at the top.
105
126
 
106
127
  ## How It Works
107
128
 
108
- When each user message is sent (before the agent touches any files), the workspace is snapshotted into an independent shadow git repository; on recall, files are restored via `git archive` and the conversation is rewound through DSH's official `sessions.fork` mechanism. Binary-safe, and your project's own git state is never touched.
129
+ 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.
109
130
 
110
- - Snapshot storage: `dsh-recall-snapshots/<SHA256(project absolute path)>/` under home, containing the shadow git repository (`git/`, tags named `snap-<messageID>`) and the index file `index.json` (message ID → snapshot time / session). Scripts run via PowerShell on Windows and bash on Linux/macOS (forked automatically by the executor mounted on the `ctx.shell` platform layer).
131
+ - Snapshot storage: `dsh-recall-snapshots/<SHA256(project absolute path)>/` under home, containing the shadow git repository (`git/`, tags named `snap-<messageID>`), the index file `index.json` (message ID → snapshot time / session), and the recall chain `lineage.json`. Scripts run via PowerShell on Windows and bash on Linux/macOS (selected automatically by platform).
111
132
  - To browse historical snapshots directly:
112
133
 
113
134
  ```powershell
@@ -129,11 +150,16 @@ pnpm install
129
150
  # 3. Restart DSH and hard-refresh the page (Ctrl+Shift+R)
130
151
  ```
131
152
 
153
+ Note: the browser-side source lives in `src/client/` (multiple files); `lib/client.js` is the esbuild bundle and is committed with the source — **you must run `npm run build` after changing `src/client/`**, otherwise the old UI keeps running (CI enforces bundle freshness). The host side (the rest of `lib/`) has no build step.
132
154
 
133
- ## License
155
+ ### Tests
134
156
 
135
- MIT
157
+ - `npm test`: pure-logic unit tests (vitest, 17 files / 227 cases, no DSH dependency, runs identically in CI and locally) — config parsing, snapshot parsers, rescue orchestration, error classification, script-template same-name-export contract, client pure functions, published-package layout, snapshot index persistence, storage caps and retention, etc.;
158
+ - `npm run test:probe`: official-API field probes (requires a local dsh installation; **must run after any dsh upgrade**) — pins fields like `renderMessageImages`/`node`/`cwd`, `atSeq`/`increaseTitle` of `sessions.fork`, `listSessions` record shape, `AgentRegistry`, and goes red on violation;
159
+ - `npm run verify:host`: assembly gate (requires a local dsh installation) — boots the plugin with a real cordis context, asserting inject declarations, endpoint registration, Config schema, and teardown cleanliness, catching assembly regressions before release;
160
+ - `npm run build`: bundle the client artifact (mandatory after `src/client/` changes); `npm run check:dsh`: dsh version inspection (pre-release).
161
+ - CI (GitHub Actions) runs `npm ci --legacy-peer-deps` + `npm test` + client bundle freshness check (probes and the assembly gate only run on machines with dsh).
136
162
 
137
- ---
163
+ ## License
138
164
 
139
- [Changelog](CHANGELOG.md)
165
+ MIT
package/README.md CHANGED
@@ -11,10 +11,11 @@
11
11
  ![DSH](https://img.shields.io/badge/DSH-0.1.1--rc.1-blue)
12
12
  ![DSH](https://img.shields.io/badge/DSH-Desktop-blue)
13
13
  ---
14
- **在任意一条你发过的消息下方**,**点「↶ 撤回」**,**工作区文件和对话历史一起回到那条消息发出之前的状态**。(dsh-0.1.1-rc.2)
14
+ **在任意一条你发过的消息下方**,**点「↶ 撤回」**,**工作区文件和对话历史一起回到那条消息发出之前的状态**(DSH 0.1.1-rc.2)。
15
15
  ---
16
16
 
17
17
  [更新日志](CHANGELOG.md)
18
+
18
19
  ## 界面预览
19
20
 
20
21
  - 撤回按钮位置
@@ -27,7 +28,7 @@
27
28
  | ![确认面板 · 变更文件清单](docs/screenshots/confirm-panel-1.png) | ![确认面板 · 回退范围说明](docs/screenshots/confirm-panel-2.png) |
28
29
 
29
30
  - 撤回后自动把消息文本回填到输入框,方便修改后重发(可在设置卡片关闭)
30
- - 设置页 · 插件配置卡片(阈值 / 排除表 / 快照管理,保存即热生效)
31
+ - 设置页 · 插件配置卡片(配置表单 / 排除表 / 快照管理,保存即热生效)
31
32
 
32
33
  ![设置页](docs/screenshots/settings-exclude-2.png)
33
34
 
@@ -37,19 +38,23 @@
37
38
  - **文件 + 对话,整段回退**:撤回的不只是聊天记录,agent 改过的文件也一并回到原样。
38
39
  - **不碰你项目自己的 git**:快照存在独立的影子 git 仓库里,你的分支、暂存区、未提交改动统统不受影响;`.git`、`node_modules` 自动排除。
39
40
  - **项目目录保持干净**:快照始终存在 `$DSH_HOME` 下,不会往项目里塞任何东西;与会话的沙箱权限无关(workspace-write / read-only 会话照常快照与回退),仅当 home 本身不可写(如指到只读盘)才降级到项目内 `.dsh-recall-snapshots`(降级时页面会提示),home 恢复后自动迁走、清理干净。
40
- - **可以反复后悔**:只要会话还在(含归档),快照全量保留、不修剪。撤回一次后还能再撤到更早;撤回时被覆盖的文件也一直找得回来。会话被彻底删除后,其快照随之清理(见下)。
41
+ - **字节级保真**(2.1.1+):快照与回退不受项目 `.gitattributes` 的 EOL 转换影响——LF/CRLF 换行、`$Id$`、二进制内容原样往返(影子仓库固化 `info/attributes` 关闭全部属性驱动转换)。
42
+ - **可以反复后悔**:撤回一次后还能再撤到更早;撤回时被覆盖的文件也一直找得回来。快照默认每工作区保留 500 条(超限自动清最旧,上限可调或关闭),会话被彻底删除后其快照随之清理。
41
43
  - **先看清单再动手**:点撤回先弹出将变更的文件清单(修改 / 恢复 / 删除),确认后才执行,不会稀里糊涂覆盖。
44
+ - **运行中防护**(2.0+):目标工作区的 agent 正在运行时拒绝预览与撤回,避免确认期间文件又被 agent 改动;预览之后若该消息又有了新快照,执行前强制重新预览(时效校验)。
45
+ - **回退失败自动救援**(2.1+):执行撤回前先自动打一份「回退前」安全快照;回退中途失败时自动恢复到回退前状态,救援也失败则给出可直接复制执行的手动恢复命令——任何路径都不留半回退现场。
42
46
  - **磁盘友好**:快照走 git delta 压缩,是增量不是整目录拷贝;超过 100MB 的大文件自动跳过(阈值可在设置卡片改)。
43
- - **自动瘦身**:定期 `git gc` 把 loose 对象压 pack(无损,快照一个不丢);会话删除后其快照自动清理;构建产物可经 `exclude.txt` 全局排除(见下)。
44
- - **失败了会说话**(1.7.0+):快照失败、跳过路径、连续失败熔断都会在页面顶部 toast 提示(同类故障 10 分钟只打扰一次),不会无声失效;失败原因进设置卡片「最近错误」。
45
- - **故障自愈**(1.7.0+):快照失败后自动清理残骸对象、连续 3 次失败起指数退避熔断(冷却后自动重试),失败路径还会清扫漏网的 git 进程与残留锁——磁盘不会被失败重试撑爆,也不会被一次异常卡死。
46
- - **个别路径进不去就跳过**(1.7.0+):嵌入式 git 仓库、无读权限等无法索引的路径不再让整条快照失败——快照照常落盘,被跳过的路径 toast 告知(撤回时既不恢复也不删它们,与排除表语义一致)。
47
- - **树形快照管理**:设置页「快照管理」按 **工作区 → 会话 → 快照** 三级树形展示,支持展开/折叠;每级右侧都有删除按钮,可一次清掉整个工作区或某个会话的全部快照,叶子显示该快照对应的消息内容摘要,方便定位“这条消息当时改了什么”。
47
+ - **自动瘦身**:定期 `git gc` 把 loose 对象压 pack(无损,快照一个不丢);会话删除后其快照自动清理;支持按条数上限与按保留天数自动清理(可配);构建产物可经 `exclude.txt` 全局排除(见下)。
48
+ - **失败了会说话**:快照失败按根因分类(git 缺失 / 磁盘满 / 无权限 / 锁冲突 / 目录冲突)并给出可行动提示,页面顶部 toast(同类故障 10 分钟只打扰一次、相邻重复合并计数),不会无声失效;失败原因进设置卡片「最近错误」。
49
+ - **故障自愈**:快照失败后自动清理残骸对象、连续 3 次失败起指数退避熔断(冷却后自动重试);失败路径还会清扫漏网的 git 进程与残留锁——多实例并发时按心跳互让,不会互踩死循环(2.1+),磁盘也不会被失败重试撑爆。
50
+ - **个别路径进不去就跳过**:嵌入式 git 仓库、无读权限等无法索引的路径不再让整条快照失败——快照照常落盘,被跳过的路径 toast 告知(撤回时既不恢复也不删它们,与排除表语义一致)。
51
+ - **树形快照管理**:设置页「快照管理」按 **工作区 → 会话 → 快照** 三级树形展示,支持展开/折叠与搜索;撤回产生的会话按 fork 链聚成「版本家族」(v1/v2/v3),每级右侧都有删除按钮,可一次清掉整个工作区或某个会话的全部快照,叶子显示该快照对应的消息内容摘要,方便定位“这条消息当时改了什么”。
48
52
 
49
53
  ## 已知限制
50
54
 
51
55
  - 快照在**消息发送时**创建,插件启用前的历史消息没有快照,不显示撤回按钮。
52
56
  - 会话第一条用户消息无法回退对话(仅文件回退),因为 fork 需要更早的 turn 边界。
57
+ - 目标工作区的 agent 正在运行时无法发起撤回(防护设计,先停止 agent 再撤回)。
53
58
  - 支持 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 兼容编写,尚未真机实测。
54
59
  - 工作区内嵌套的其他 git 仓库(子目录自带 `.git`)无法索引:快照对其余部分照常(fail-open,页面会提示跳过了哪些路径),但其内容不参与回退。
55
60
  - 文件名含换行/TAB 的极端情形不在 diff 清单的解析能力内(概率可忽略)。
@@ -57,7 +62,7 @@
57
62
 
58
63
  ## 安装
59
64
 
60
- 前置:git CLI(未装时撤回按钮不出现,页面顶部会提示安装 git,不影响 DSH 运行);Windows 上 PowerShell 5.1 / 7 均可,Linux/macOS 需 bash + git;DSH 0.1.0-rc.x(依赖版本见 `peerDependencies`)。
65
+ 前置:git CLI(未装时撤回按钮不出现,页面顶部会提示安装 git,不影响 DSH 运行);Windows 上 PowerShell 5.1 / 7 均可,Linux/macOS 需 bash + git;DSH 0.1.1-rc.x(依赖版本见 `peerDependencies`)。
61
66
 
62
67
 
63
68
  - DSH 官方插件命令:安装并自动挂载进 web profile
@@ -80,15 +85,34 @@ pm2 restart <你的dsh进程名> # 若用 pm2 托管
80
85
 
81
86
  ## 使用
82
87
 
83
- 1. 鼠标悬停任意**插件启用后发送**的用户消息,复制按钮左侧出现「↶ 撤回」。
88
+ 1. 鼠标悬停任意**插件启用后发送**的用户消息(含 agent 运行中插入的转向指令消息),复制按钮左侧出现「↶ 撤回」。
84
89
  2. 点击 → 确认面板展示将变更的文件清单(修改 / 恢复 / 删除)。
85
90
  3. 点「确认回退」→ 文件恢复到该消息发送前的状态;视图切到新会话(该消息及之后的对话移除),原会话归档、随时可找回。
86
91
 
92
+ ## 配置项
93
+
94
+ 全部配置可在「**设置 → 插件配置 → 撤回插件**」卡片可视化修改(保存即热生效,无需重启),也可在 profile 的 `cordis.patch.yml` 按 `id: recall` 重述 insert 行改写;env 变量仅覆盖 gc 两项且优先级最高(设了 env 的字段在卡片里锁定)。
95
+
96
+ | 配置项 | 默认值 | 说明 |
97
+ | --- | --- | --- |
98
+ | `gcSnaps` | 50 | 每积累多少条快照触发一次 `git gc`(env `DSH_RECALL_GC_SNAPS` 可强制覆盖) |
99
+ | `gcHours` | 24 | 距上次 gc 超过多少小时触发(与条数先到先触发;env `DSH_RECALL_GC_HOURS`) |
100
+ | `maxFileBytes` | 104857600(100MB) | 超过该大小的文件不进快照、不被回退触碰 |
101
+ | `maxSnapshotsPerWorkspace` | 500 | 每个工作区保留的最大快照数,超限自动删除最旧的;0 = 不限制 |
102
+ | `retentionDays` | 0 | 按天数保留快照,超期自动删除;0 = 不启用(与条数上限各自独立生效) |
103
+ | `baseExcludes` | `.git`、`node_modules/`、`.dsh-recall-snapshots/`、`dsh-recall-snapshots/` | 基础排除表(gitignore 语法,优先级低于 exclude.txt) |
104
+ | `refillDraft` | true | 撤回后把被撤回的消息文本回填到输入框 |
105
+ | `snapshotEnabled` | true | 快照总开关(关闭只冻结新建,已有快照仍可撤回) |
106
+ | `archiveOriginal` | true | 撤回后归档原会话(关闭后原会话保留在会话列表中) |
107
+
108
+ 设置卡片另提供「恢复默认」(一键重置全部字段)与「最近错误」查看/清空。
109
+
87
110
  ## 快照维护与清理
88
111
 
89
- 快照全量保留的前提是「会话还有找回的可能」,在此之上插件自动控制磁盘占用,无需手动管理:
112
+ 插件自动控制磁盘占用,无需手动管理:
90
113
 
91
- - **定期 gc**:每 50 条快照或距上次 gc 24 小时(先到先触发),后台执行 `git gc` 把 loose 对象压成 pack。无损操作——所有快照照常可回退。节流凭据写在影子仓库内的 `gc.stamp`,重启 DSH 不会重置周期。两个阈值可在「插件配置」的撤回插件卡片里直接改(保存即热生效,无需重启),也可用环境变量强制覆盖:`DSH_RECALL_GC_SNAPS`、`DSH_RECALL_GC_HOURS`。
114
+ - **定期 gc**:每 50 条快照或距上次 gc 24 小时(先到先触发,阈值可配),后台执行 `git gc` 把 loose 对象压成 pack。无损操作——所有快照照常可回退。节流凭据写在影子仓库内的 `gc.stamp`,重启 DSH 不会重置周期。
115
+ - **条数上限与保留天数**:每工作区快照默认上限 500 条(超限清最旧);也可按 `retentionDays` 设保留天数,两者独立触发、都可在配置卡片调整或关闭。
92
116
  - **会话删除联动清理**:会话被彻底删除(会话日志从磁盘消失)后,下一次维护会自动删除该会话的全部快照并释放空间。**归档不算删除**——撤回功能自己归档的原会话日志仍在,快照保留、随时可从归档找回。判断很保守:会话只是冷着(不内存里)不会误清;无法核实日志状态时宁可不清。
93
117
  - **用户自定义排除**:打开「**设置 → 插件配置 → 撤回插件**」卡片(默认收起,点卡片头展开)即可可视化编辑快照排除项——输入路径或模式回车即加、常用模式(`dist/`、`*.log`、`.env` 等)一键追加、保存后下一次快照/回退立即生效,无需重启。也可以直接编辑 home 下 `dsh-recall-snapshots/exclude.txt`(即 `$DSH_HOME/dsh-recall-snapshots/exclude.txt`,未设置时为 `~/.dsh/dsh-recall-snapshots/exclude.txt`;UTF-8),一行一条 gitignore 风格 pattern(`#` 开头为注释),两种方式编辑的是同一份配置,例如:
94
118
 
@@ -99,14 +123,14 @@ pm2 restart <你的dsh进程名> # 若用 pm2 托管
99
123
  *.log
100
124
  ```
101
125
 
102
- 对所有项目生效(home 不可写而降级到项目内存储时,该工作区有独立的排除配置,设置页会分卡片列出)。新增排除只影响之后的快照;**回退到更早的快照时,当时尚未排除的文件仍会被恢复**(回到当时的状态,这正是回退语义)。想彻底清掉已进快照的目录,可手动删除 home 下 `dsh-recall-snapshots/` 里对应项目的哈希目录。设置卡片依赖 DSH 自带设置页(0.1.0-rc.x 均含);极旧版本看不到卡片时,直接编辑文件等效。
103
- - **树形快照管理**:打开「**设置 → 插件配置 → 撤回插件 → 快照管理**」可看到树形列表——第一级工作区(文件夹名)、第二级会话(会话标题)、第三级快照(时间 + 消息内容摘要,悬停看完整内容)。工作区和会话节点可展开/折叠;每一级右侧都有删除按钮,删除前会二次确认。删除工作区 = 清掉该工作区全部快照;删除会话 = 清掉该工作区内该会话的全部快照;删除叶子 = 只删那一条快照。
126
+ 对所有项目生效(home 不可写而降级到项目内存储时,该工作区有独立的排除配置,设置页会分卡片列出)。新增排除只影响之后的快照;**回退到更早的快照时,当时尚未排除的文件仍会被恢复**(回到当时的状态,这正是回退语义)。想彻底清掉已进快照的目录,可手动删除 home 下 `dsh-recall-snapshots/` 里对应项目的哈希目录。
127
+ - **树形快照管理**:打开「**设置 → 插件配置 → 撤回插件 → 快照管理**」可看到树形列表——第一级工作区(文件夹名)、第二级会话(会话标题,撤回链聚成版本家族)、第三级快照(时间 + 消息内容摘要,悬停看完整内容)。支持搜索与「加载更多」;工作区和会话节点可展开/折叠;每一级右侧都有删除按钮,删除前会二次确认。删除工作区 = 清掉该工作区全部快照;删除会话 = 清掉该工作区内该会话的全部快照;删除叶子 = 只删那一条快照;顶部另有带确认的「全部删除」。
104
128
 
105
129
  ## 工作原理
106
130
 
107
- 每条用户消息发送时(agent 动文件之前),工作区被快照进一个独立的影子 git 仓库;撤回时用 `git archive` 恢复文件、通过 DSH 官方 `sessions.fork` 机制把会话切到该消息之前。二进制安全,全程不触碰项目自身的 git 状态。
131
+ 每条用户消息发送时(agent 动文件之前),工作区被快照进一个独立的影子 git 仓库;撤回时先打「回退前」安全快照、再用 `git archive` 恢复文件、通过 DSH 官方 `sessions.fork` 机制把会话切到该消息之前。二进制与换行符安全,全程不触碰项目自身的 git 状态。
108
132
 
109
- - 快照存储:home 下 `dsh-recall-snapshots/<SHA256(项目绝对路径)>/`,内含影子 git 仓库(`git/`,tag 名为 `snap-<消息ID>`)与索引文件 `index.json`(消息 ID → 快照时间 / 会话)。Windows 上脚本走 PowerShell,Linux/macOS 走 bash(按 `ctx.shell` 平台层挂载的执行器自动分叉)。
133
+ - 快照存储:home 下 `dsh-recall-snapshots/<SHA256(项目绝对路径)>/`,内含影子 git 仓库(`git/`,tag 名为 `snap-<消息ID>`)、索引文件 `index.json`(消息 ID → 快照时间 / 会话)与撤回链 `lineage.json`。Windows 上脚本走 PowerShell,Linux/macOS 走 bash(按平台自动分叉)。
110
134
  - 想直接翻历史快照:
111
135
 
112
136
  ```powershell
@@ -128,15 +152,16 @@ pnpm install
128
152
  # 3. 重启 DSH + 硬刷新页面(Ctrl+Shift+R)
129
153
  ```
130
154
 
155
+ 注意:浏览器端代码的源码在 `src/client/`(多文件),`lib/client.js` 是 esbuild 打包产物并随源码提交——**改 `src/client/` 后必须跑 `npm run build`**,否则运行的是旧 UI(CI 有产物新鲜度校验)。Host 端(`lib/` 其余文件)无构建步骤。
131
156
 
132
157
  ### 测试
133
158
 
134
- - `npm test`:纯逻辑单测(vitest,无 DSH 依赖,CI 与本地同跑)——配置解析、快照解析器、脚本模板同名导出契约、客户端纯函数、发布包内容布局、快照索引持久化、存储总量上限;
135
- - `npm run test:probe`:官方 API 字段探针(依赖本机 dsh 安装;dsh 升级后本地必跑)——钉住 `renderMessageImages`/`node`/`cwd`、`sessions.fork` 的 `atSeq`/`increaseTitle`、`listSessions` 记录结构、`AgentRegistry` 等字段,违反即红(合规清单 #8 的机器化)。
136
- - CI(GitHub Actions)跑 `npm ci --legacy-peer-deps` + `npm test`(探针只在有 dsh 的机器跑)。
159
+ - `npm test`:纯逻辑单测(vitest,17 个文件 227 例,无 DSH 依赖,CI 与本地同跑)——配置解析、快照解析器、救援编排、错误分类、脚本模板同名导出契约、客户端纯函数、发布包内容布局、快照索引持久化、存储上限与保留天数等;
160
+ - `npm run test:probe`:官方 API 字段探针(依赖本机 dsh 安装;dsh 升级后本地必跑)——钉住 `renderMessageImages`/`node`/`cwd`、`sessions.fork` 的 `atSeq`/`increaseTitle`、`listSessions` 记录结构、`AgentRegistry` 等字段,违反即红;
161
+ - `npm run verify:host`:装配门禁(依赖本机 dsh 安装)——用真实 cordis 起插件,断言 inject 声明、端点注册、Config schema、卸载清理,装配回归发版前即可拦截;
162
+ - `npm run build`:打包 client 产物(改 `src/client/` 后必跑);`npm run check:dsh`:dsh 版本巡检(发布前)。
163
+ - CI(GitHub Actions)跑 `npm ci --legacy-peer-deps` + `npm test` + client 产物新鲜度校验(探针与装配门禁只在有 dsh 的机器跑)。
137
164
 
138
165
  ## License
139
166
 
140
167
  MIT
141
-
142
-
package/lib/client.js CHANGED
@@ -464,6 +464,7 @@ function buildRecallNode(React, util, ctx, sessionsSvc, workspacesSvc) {
464
464
  changes: res.changes || [],
465
465
  total: typeof res.total === "number" ? res.total : (res.changes || []).length,
466
466
  truncated: Boolean(res.truncated),
467
+ treeId: res.treeId || null,
467
468
  time: res.time || null,
468
469
  cutSeq: typeof res.cutSeq === "number" ? res.cutSeq : null
469
470
  });
@@ -477,7 +478,7 @@ function buildRecallNode(React, util, ctx, sessionsSvc, workspacesSvc) {
477
478
  const previewCut = typeof recall.cutSeq === "number" ? recall.cutSeq : null;
478
479
  const previewTotal = typeof recall.total === "number" ? recall.total : changes.length;
479
480
  setRecall({ stage: "executing", changes });
480
- api("execute", { messageId, sessionId, previewTotal, previewAt: Date.now() }).then(async (res) => {
481
+ api("execute", { messageId, sessionId, previewTotal, previewTreeId: recall.treeId || void 0, previewAt: Date.now() }).then(async (res) => {
481
482
  if (!res || !res.ok) {
482
483
  if (res && res.code === "STALE") {
483
484
  setRecall({ stage: "loading" });
@@ -491,6 +492,7 @@ function buildRecallNode(React, util, ctx, sessionsSvc, workspacesSvc) {
491
492
  changes: res2.changes || [],
492
493
  total: typeof res2.total === "number" ? res2.total : (res2.changes || []).length,
493
494
  truncated: Boolean(res2.truncated),
495
+ treeId: res2.treeId || null,
494
496
  time: res2.time || null,
495
497
  cutSeq: typeof res2.cutSeq === "number" ? res2.cutSeq : null
496
498
  });
@@ -754,6 +756,17 @@ function buildSettingsCards(React, util, sessionsSvc) {
754
756
  setTotal(typeof res.total === "number" ? res.total : (res.items || []).length);
755
757
  fetchTitles(res.items || []);
756
758
  fetchMessages(res.items || []);
759
+ if (res.stale) {
760
+ api("manage", { op: "list", limit: useLimit }).then((res2) => {
761
+ if (res2 && res2.ok && !res2.stale) {
762
+ setItems(res2.items || []);
763
+ setTotal(typeof res2.total === "number" ? res2.total : (res2.items || []).length);
764
+ fetchTitles(res2.items || []);
765
+ fetchMessages(res2.items || []);
766
+ }
767
+ }).catch(() => {
768
+ });
769
+ }
757
770
  }
758
771
  api("manage", { op: "lineage" }).then((res2) => {
759
772
  if (res2 && res2.ok && Array.isArray(res2.lineage)) setLineage(res2.lineage);
@@ -0,0 +1,78 @@
1
+ /**
2
+ * dsh-recall-plugin — dump 输出解析纯函数(PF-4 / PF-8)
3
+ *
4
+ * storesDumpScript / excludeDumpScript(两平台脚本模板)定界输出的解析器。
5
+ * 放独立模块而非 index.js:routes-manage 也要用 parseExcludeDump,放 index.js
6
+ * 会形成 index → routes-manage → index 的循环依赖;纯函数无依赖,独立成
7
+ * 文件最干净。模块级导出供单测(tests/unit/stores-dump.test.js 等),index.js
8
+ * re-export 保持既有 import 路径稳定。
9
+ */
10
+
11
+ // 解析 storesDumpScript 的定界输出:dir → { root, entries, lineage }。逐行
12
+ // 状态机(==DIR / ROOT / INDEXBEGIN..INDEXEND / LINEAGEBEGIN..LINEAGEEND),
13
+ // 单个 store 的 JSON 损坏只丢它自己。
14
+ // PF-4:LINEAGE 段承载 lineage.json 原文(与 INDEX 段同构)——manage lineage
15
+ // 原实现对每个 root 串行 loadLineage(每 root 一条进程,20 工作区 ≈ 10s),
16
+ // 并入 dump 后零新增进程。无 LINEAGE 段(脚本/Host 版本错位的理论场景)按
17
+ // 无 lineage 处理,解析容错;lineage.json 损坏按空处理(与 loadLineage 的
18
+ // 既有语义一致:损坏不致命,树退化为普通分组)。
19
+ export function parseStoresDump(text) {
20
+ const map = new Map()
21
+ let cur = null
22
+ let inIndex = false
23
+ let indexLines = []
24
+ let inLineage = false
25
+ let lineageLines = []
26
+ function flush() {
27
+ if (!cur) return
28
+ const raw = indexLines.join('\n').trim()
29
+ if (raw) {
30
+ try {
31
+ const arr = JSON.parse(raw)
32
+ if (Array.isArray(arr)) cur.entries = arr
33
+ } catch (error) { /* index 损坏按无索引处理 */ }
34
+ }
35
+ const lraw = lineageLines.join('\n').trim()
36
+ if (lraw) {
37
+ try {
38
+ const larr = JSON.parse(lraw)
39
+ if (Array.isArray(larr)) {
40
+ cur.lineage = larr.filter((e) => e && typeof e.childId === 'string' && typeof e.parentId === 'string')
41
+ }
42
+ } catch (error) { /* lineage 损坏按无处理(不隔离),与 loadLineage 一致 */ }
43
+ }
44
+ map.set(cur.dir, cur)
45
+ cur = null
46
+ }
47
+ for (const line of String(text).split(/\r?\n/)) {
48
+ if (line.indexOf('==DIR ') === 0) { flush(); cur = { dir: line.slice(6).trim(), root: null, entries: null, lineage: null }; inIndex = false; inLineage = false; indexLines = []; lineageLines = []; continue }
49
+ if (!cur) continue
50
+ if (line.indexOf('ROOT ') === 0) { const v = line.slice(5).trim(); cur.root = v || null; continue }
51
+ if (line === 'INDEXBEGIN') { inIndex = true; indexLines = []; continue }
52
+ if (line === 'INDEXEND') { inIndex = false; continue }
53
+ if (line === 'LINEAGEBEGIN') { inLineage = true; lineageLines = []; continue }
54
+ if (line === 'LINEAGEEND') { inLineage = false; continue }
55
+ if (inIndex) indexLines.push(line)
56
+ else if (inLineage) lineageLines.push(line)
57
+ }
58
+ flush()
59
+ return map
60
+ }
61
+
62
+ // 解析 excludeDumpScript 的定界输出(PF-8):EXCLBEGIN <path> / base64 单行
63
+ // / EXCLEND → Map<路径, 原文>。内容行是 base64(ASCII 单行),exclude.txt
64
+ // 里的任意文本(空行/注释/恰好像标记的行)都不会打乱状态机;文件不存在的
65
+ // 段内容为空串(按「尚未配置」处理)。
66
+ export function parseExcludeDump(text) {
67
+ const map = new Map()
68
+ let cur = null
69
+ for (const line of String(text || '').split(/\r?\n/)) {
70
+ if (line.indexOf('EXCLBEGIN ') === 0) { cur = line.slice('EXCLBEGIN '.length).trim(); map.set(cur, ''); continue }
71
+ if (line === 'EXCLEND') { cur = null; continue }
72
+ if (cur !== null && line) {
73
+ // base64 损坏按空处理(与「文件不存在」同语义,不致命)
74
+ try { map.set(cur, Buffer.from(line, 'base64').toString('utf8')) } catch (error) { /* 保持空串 */ }
75
+ }
76
+ }
77
+ return map
78
+ }
package/lib/index.js CHANGED
@@ -12,6 +12,11 @@
12
12
  */
13
13
 
14
14
  import { createConfig, Config, DEFAULTS } from './config.js'
15
+ import { parseStoresDump, parseExcludeDump } from './dump-parse.js'
16
+
17
+ // dump 解析纯函数住 dump-parse.js(避免 routes-manage 反向 import index 的
18
+ // 循环依赖),这里 re-export 保持既有 import 路径稳定
19
+ export { parseStoresDump, parseExcludeDump }
15
20
  import { createRuntime } from './store.js'
16
21
  import { createSnapshots, rescueRollback } from './snapshots.js'
17
22
  import { createMaintenance } from './maintenance.js'
@@ -76,9 +81,16 @@ export function apply(ctx, config) {
76
81
  // 快照管理列表的结果缓存(apply 级跨请求共享):30s 缓存让二次打开即时;
77
82
  // delete 与新快照落地时失效。listCache/excludeCache 是可变 holder——routes
78
83
  // 层改属性(items/payload),本文件的事件接线读同一引用。
79
- const listCache = { at: 0, items: null }
84
+ // PF-6:事件接线不再清空 items 而是 stale 标记——list 端点先用旧 items
85
+ // 立即应答(对话中打开快照管理不再等全量 dump),后台 dump 更新缓存
86
+ // (refreshing 持有进行中的 promise 做 in-flight 去重,stale 期间重复
87
+ // list 不重复起进程);Client 收到 stale 标记静默再拉一次。
88
+ const listCache = { at: 0, items: null, stale: false, refreshing: null }
80
89
  // 排除配置枚举缓存(30s):exclude-set 成功写入后立即失效。
81
90
  const excludeCache = { at: 0, payload: null }
91
+ // 全量磁盘占用缓存(30s,PF-3):删除/gc 后失效(占用变化必须立即可见),
92
+ // 每条消息不失效——快照带来的增量由 TTL 到期自然覆盖,不然 TTL 形同虚设。
93
+ const usageCache = { at: 0, payload: null }
82
94
 
83
95
  // 会话标题/文本两段式读取(live 秒回,冷会话由 Client 异步补齐)
84
96
  const sessionInfo = createSessionInfo(ctx)
@@ -223,37 +235,6 @@ export function apply(ctx, config) {
223
235
  return cwds
224
236
  }
225
237
 
226
- // 解析 storesDumpScript 的定界输出:dir → { root, entries }。逐行状态机
227
- // (==DIR / ROOT / INDEXBEGIN..INDEXEND),单个 store 的 JSON 损坏只丢它自己。
228
- function parseStoresDump(text) {
229
- const map = new Map()
230
- let cur = null
231
- let inIndex = false
232
- let indexLines = []
233
- function flush() {
234
- if (!cur) return
235
- const raw = indexLines.join('\n').trim()
236
- if (raw) {
237
- try {
238
- const arr = JSON.parse(raw)
239
- if (Array.isArray(arr)) cur.entries = arr
240
- } catch (error) { /* index 损坏按无索引处理 */ }
241
- }
242
- map.set(cur.dir, cur)
243
- cur = null
244
- }
245
- for (const line of String(text).split(/\r?\n/)) {
246
- if (line.indexOf('==DIR ') === 0) { flush(); cur = { dir: line.slice(6).trim(), root: null, entries: null }; inIndex = false; indexLines = []; continue }
247
- if (!cur) continue
248
- if (line.indexOf('ROOT ') === 0) { const v = line.slice(5).trim(); cur.root = v || null; continue }
249
- if (line === 'INDEXBEGIN') { inIndex = true; indexLines = []; continue }
250
- if (line === 'INDEXEND') { inIndex = false; continue }
251
- if (inIndex) indexLines.push(line)
252
- }
253
- flush()
254
- return map
255
- }
256
-
257
238
  // 一条 shell dump 全部 store 元数据(容器子目录 + 降级候选目录的 root.txt
258
239
  // 与 index.json),manage list 与 delete 兜底共用。
259
240
  async function dumpStores() {
@@ -263,6 +244,9 @@ export function apply(ctx, config) {
263
244
  const text = rt.scripts.stripBom(await rt.runShell(rt.scripts.storesDumpScript(container || '', extras), { timeoutMs: 120000, stdoutMaxBytes: 8388608 }))
264
245
  return parseStoresDump(text)
265
246
  } catch (error) {
247
+ // 同 refreshListCacheInBackground:dump 失败按空 Map 继续是设计行为,
248
+ // 但失败原因必须留痕,否则列表缺数据时无从排查
249
+ console.error('recall stores dump failed:', String(error && error.stack || error))
266
250
  return new Map()
267
251
  }
268
252
  }
@@ -336,7 +320,7 @@ export function apply(ctx, config) {
336
320
  ctx, rt, snaps, maint, state, cfg, supported,
337
321
  enqueue, agentBusy, runLimited, readJsonBody, sendJson, errBody,
338
322
  listExcludeFiles, dumpStores, locateSnapshotOnDisk, collectAllSnapshotRecords,
339
- listCache, excludeCache, sessionInfo, titleFromEvents, messageTextFromEvents,
323
+ listCache, excludeCache, usageCache, sessionInfo, titleFromEvents, messageTextFromEvents,
340
324
  // readSettings 传活绑定而非当前引用(A1):dsh-settings 服务晚挂载时
341
325
  // setSource 会重绑定 readSettings——按值捕获的副本停在旧闭包(入口
342
326
  // config),config-reset 会按旧值「恢复默认」。活绑定让消费者每次调用
@@ -387,7 +371,8 @@ export function apply(ctx, config) {
387
371
  // maybeMaintain 照常跑——已停增的存储仍需被 gc/清理治理。
388
372
  .then(() => (cfg.snapshotEnabled ? snaps.captureSnapshot(session.id, messageId, time) : null))
389
373
  .then(() => maint.maybeMaintain(session.id))
390
- .then(() => { listCache.items = null })
374
+ // PF-6:不清 items 只标 stale——列表先按旧数据应答、后台补新(见 listCache 注释)
375
+ .then(() => { listCache.stale = true })
391
376
  .catch((error) => rt.recordError('recall snapshot error: ' + String(error)))
392
377
  })
393
378