dsh-rewind-plugin 0.4.2 → 0.5.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.
@@ -0,0 +1,72 @@
1
+ # Contributing
2
+
3
+ Thanks for considering a contribution to dsh-rewind. This file is short on
4
+ purpose: the authoritative spec for how the repo works is `AGENTS.md` (read it
5
+ first), and the docs live under `docs/` with an index in `docs/README.md`.
6
+
7
+ ## Project principles
8
+
9
+ - **Focused on purpose** — one thing: in-window rewind to any earlier user
10
+ message, never forking a session.
11
+ - **Security first** — session logs are append-only; file restores stay inside
12
+ the plugin's own backup directory. See `SECURITY.md`.
13
+ - **Minimal** — avoid over-abstraction; keep the plugin light and maintainable.
14
+
15
+ ## Prerequisites
16
+
17
+ - Node `^22.19.0 || >=24.0.0` (see `engines` in `package.json`), npm.
18
+
19
+ ## Setup and commands
20
+
21
+ ```sh
22
+ npm install # devDeps from the npm registry
23
+ npm run build # esbuild → lib/ (host ESM + client closure + types)
24
+ npm run check # one-shot full gate: typecheck + test + build + verify:host + pack --dry-run
25
+ npm run typecheck # tsc --noEmit (host / client / client-test)
26
+ npm test # vitest: unit + compatibility suites
27
+ npm run verify:host # end-to-end host verification (full check suite)
28
+ ```
29
+
30
+ `prepare` runs the build, so `npm pack` / `npm publish` always carry a fresh
31
+ `lib/` and `LICENSE`.
32
+
33
+ ## Before you open a PR
34
+
35
+ - **Every change must pass** `npm run check`.
36
+ - Commit messages use conventional commits with **English** subjects
37
+ (`feat` / `fix` / `docs` / `test` / `refactor` / `chore` / …). Code comments
38
+ are written in English.
39
+ - Keep `rewind.ts` pure and `snapshot.ts` host-independent — if a change needs
40
+ I/O or harness types in the planning layer, that is a design smell.
41
+
42
+ ## Documentation rules
43
+
44
+ - New/changed behavior that is durable (formats, contracts, compatibility
45
+ findings) must update the relevant doc in the same PR:
46
+ - **`docs/format.md`** — any change to the on-disk format (bump version,
47
+ migrate, or move the state root; no silent re-interpretation).
48
+ - **`docs/contract/client-contract.md`** — any change to the meaning of
49
+ `@<seq>` / `sourceEventSeq` / `data-dsh-rewind-hidden`; breaking a listed
50
+ stability tier is a minor/major version bump.
51
+ - **`docs/compat/audit.md`** — new compatibility findings (it is the single
52
+ source of truth; other docs link to it instead of restating).
53
+ - **`SECURITY.md`** — any change to the security model (trust boundary,
54
+ mutation gates, containment, crash handling).
55
+ - Bilingual docs use the `.md` / `.zh.md` file split; keep the two mirrors in
56
+ sync.
57
+
58
+ ## Testing expectations
59
+
60
+ - Pure planning (`rewind.ts`, `hidden.ts`) → unit tests in `tests/`.
61
+ - Store behavior (`snapshot.ts`) → `tests/snapshot.test.ts`, plus crash-safety
62
+ scenarios in `tests/crash-safety.test.ts` via the test-only `crash` seam
63
+ (`RestoreRunOptions.crash`).
64
+ - Harness interaction → the compatibility suites
65
+ (`compat-invariants` / `compat-interop` / `compat-gaps`) and
66
+ `scripts/verify-host.mjs`.
67
+
68
+ ## Releasing
69
+
70
+ Releases are CI-driven via GitHub Actions Trusted Publishing (OIDC, no stored
71
+ token): push a `v<version>` tag and CI publishes with Sigstore provenance. Full
72
+ details: `docs/release/release.md`.
package/README.en.md CHANGED
@@ -3,8 +3,8 @@
3
3
  Conversation rewind for DeepSeek Harness: **rewind the conversation to any earlier user message in one click, in the same window** — no new branch, no window switch, with optional workspace-file restore (full Claude Code `/rewind` semantics).
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/dsh-rewind-plugin.svg)](https://www.npmjs.com/package/dsh-rewind-plugin)
6
- [![npm license](https://img.shields.io/npm/l/dsh-rewind-plugin.svg)](https://github.com/SiriLee/dsh-rewind/blob/main/LICENSE)
7
6
  [![npm downloads](https://img.shields.io/npm/dm/dsh-rewind-plugin.svg)](https://www.npmjs.com/package/dsh-rewind-plugin)
7
+ [![tests](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FSiriLee%2Fdb3b9260351c2b26eb3d201c2ed29df1%2Fraw%2Fbadge.json)](https://github.com/SiriLee/dsh-rewind/actions/workflows/ci.yml)
8
8
 
9
9
  > English | [中文](README.md)
10
10
 
@@ -12,7 +12,7 @@ A deliberately focused plugin with one job: **rewind to any user message, no mat
12
12
 
13
13
  - **Rewinding is time-travel** — the target message and everything after it (agent replies, tool calls) are withdrawn from the model context *and* the rendered transcript at once, with no new session and no window switch; the target's text is offered back in the composer so you can edit and re-send it — **truly seamless and convenient by design**.
14
14
  - **Lightweight workspace backup** — Claude Code-aligned behavior: only file-writing tools are tracked, a lightweight before-backup is **persisted on disk**, and your git repository is never touched or relied on. One lightweight plugin gives you a **complete** agentic rewind capability.
15
- - **Privacy-first** — the plugin never deletes or rewrites the session log (append-only) and never actually deletes any of your conversation; file restores stay inside the plugin's own backup directory.
15
+ - **Privacy-first** — the plugin never deletes or rewrites the session log (append-only) and never actually deletes any of your conversation; file restores stay inside the plugin's own backup directory. Full security model: [SECURITY.md](SECURITY.md).
16
16
  - **A complete test system** — unit, probe, and end-to-end host verification, covering compatibility probing, log replay, resume, cross-restart and other scenarios; maintained continuously as the harness evolves to ensure feature stability.
17
17
 
18
18
  ## Preview
@@ -69,48 +69,42 @@ Compared with the common approaches, here is the trade-off this plugin makes on
69
69
 
70
70
  ## How it works
71
71
 
72
- The mechanism is the same lineage as Claude Code's checkpointing (Claude Code's file history is also per-file records plus a re-scan of tracked files at each message not a whole-tree snapshot). This plugin implements the same semantics on dsh:
72
+ The whole design rests on two principles, simple but deliberate: **the conversation half "masks, never deletes", and the file half "backs up before the write, reconciles against the real disk before restoring."** It shares lineage with Claude Code's checkpointing Claude Code's file history is also per-file records plus a re-scan of tracked files at every message, not a whole-tree snapshot. This plugin brings the same semantics to dsh, and makes them lighter and more robust.
73
73
 
74
- ### 1. Conversation rewind: in place, without losing the log
74
+ ### 1. Conversation rewind: a single "mask", not a delete
75
75
 
76
- The session log is **append-only** — the plugin never rewrites history. A rewind appends an **empty-content marker** that "shadows out" everything after the target message, so both the model and the UI only see the part before it:
76
+ `append-only` is a hard rule: the session log only grows and is never rewritten — the foundation of auditability and privacy. A rewind never touches history; it makes a single move: append **one empty-content message marker** to the end of the log and use it to "mask out" everything after the target message, so the model and the UI see only the part before it.
77
77
 
78
- - The marker is **empty** — it never enters the model context and never renders as conversation content; what you and the agent see is exactly how the conversation looked at the target;
79
- - Because this is "shadowing" rather than "deleting", **every withdrawn event stays in the log**, fully auditable;
80
- - The marker reuses the number of the last-started turn and carries its own step frame so dsh's own machinery (log replay, `/compact`, resume preflight) recognizes it correctly and never mistakes it for a real message (these compatibility details are pinned by dedicated probe tests, see [Development](#development)).
78
+ - The marker is **empty** — it never enters the model context and never renders as conversation content; what you and the model see is exactly how the conversation looked at the target. True "in place".
79
+ - Because this is **masking, not deleting**, every withdrawn event stays in the log auditable, traceable, and in principle manually recoverable.
80
+ - The marker is deeply **aware of dsh internals**: it reuses the **last-started turn** number (never "last turn + 1") and carries its own **ghost step frame**. So the harness's own log replay, `/compact`, and resume preflight all recognize it and never mistake it for a real message.
81
81
 
82
- <details>
83
- <summary><b>Implementation details (for maintainers)</b></summary>
84
-
85
- The plugin appends an **empty-content marker** `assistant/message` into the session log whose `surfaceOp: { op: 'replace', start, end }` replaces every surface node after the target message with the marker:
82
+ > **Design highlight**: the entire conversation rewind is **a single append**. It's deterministic, auditable, and — because the log was never broken — a "clean" time-travel. Minimal action, complete semantics. The compatibility subtleties with the harness (ghost step frame, reused turn number) are where this plugin is genuinely professional — each is pinned by a dedicated probe test.
86
83
 
87
- - The marker carries `sourceEventSeqs` covering every shadowed node, and `Session.append`'s surface rules validate the cut (a contiguous range on the current surface).
88
- - Because the marker is **empty**, the harness derives it to `null` — it never enters the model context and never renders as conversation content.
89
- - The marker's **turn number reuses the last started turn** (`markerTurnOf`), never `lastTurn + 1`: the harness numbers its next real turn exactly `last turn/start + 1`, so a `maxTurn + 1` marker would sit *before* that `turn/start` — the client conversation builder rejects the ordering (`…turn-tail… received an update before its start Match`) and history load fails. Reusing an already-consumed turn makes the marker a harmless trailing update on the previous completed turn's tail — it can never collide with a future turn.
90
- - The marker rides a **ghost step frame** — its own `step/start` … `step/end` with a fresh step number (`markerStepOf`) — because the harness token-meter requires every `assistant/message` to sit inside an open step of the same turn/step; a bare idle-time marker would fail its replay and break `/compact` for the session.
84
+ ### 2. File restore: lightweight checkpointing, "back up before the change"
91
85
 
92
- </details>
86
+ The file half follows Claude Code's checkpoint semantics — **per-file before-backups plus a re-scan of tracked files at each message**, not a whole-tree snapshot. This trade-off saves space, and it's actually more complete:
93
87
 
94
- A running turn (LLM thinking / streaming) is force-stopped first and the rewind waits for quiescence; if it can't stop, the rewind is aborted with an error.
88
+ - **Before-backup**: tracks the write-class tools (`write`, `edit`, `str_replace_editor`) and stores the original content **before** each write. Timing is the key it captures after any approval gate lets the call through: an approval short-circuit can't skip the backup, and a denied call never records; a read failure only warns, never blocks the write. Backups are grouped by conversation turn and **persist on disk** across restarts.
89
+ - **External changes count too**: at every user-message boundary the plugin re-checks all tracked files — edits or deletions made outside the write tools are recorded as well and restored by a later rewind. "Lightweight" but not "incomplete".
90
+ - **Reconcile against the real disk before restoring**: the most interesting decision. At rewind time the plugin reads each file's current content and compares it to the target state — **only files that actually differ are touched**: modified files are written back to their earliest backup, files created after the target are deleted, files already matching are skipped. Repeated rewinds are therefore **idempotent with zero side effects** and never produce "ghost impact".
91
+ - **Safety boundary**: symlinks / hard links are skipped so one restore can't clobber another name of the same file; paths are sanitized so nothing ever escapes the backup root; a per-file failure never aborts the pass.
95
92
 
96
- ### 2. File restore: before-backup + external-change tracking + disk reconciliation
93
+ > **Design highlight**: **"reconcile against the real disk before acting"** is the most insightful decision in this checkpoint design — it never assumes blindly; it trusts the disk, doing what must be done and skipping what must not.
97
94
 
98
- The plugin tracks the write-class tools — `write`, `edit`, `str_replace_editor`:
95
+ ### Design highlights
99
96
 
100
- 1. **Before-backup**: the original content of every file is stored **before** it is written (captured after any approval gate lets the call through — an approval short-circuit cannot skip the backup, and a denied call never records; a read failure only warns, never blocks the write). Backups are grouped by conversation turn and **persist across restarts** (100 most recent groups per session).
101
- 2. **External changes are tracked too**: at every user-message boundary the plugin re-checks all tracked files — edits or deletions made outside the write tools are recorded as well and restored by a later rewind.
102
- 3. **Real disk reconciliation before restoring**: at rewind time the plugin reads each file's current content and compares it with the target state — **only files that actually differ are touched**: modified files are written back to their earliest backup, files created after the target are deleted, files already matching are skipped (repeated rewinds have zero side effects). Symlinks / hard links are skipped to avoid collateral damage.
103
-
104
- <details>
105
- <summary><b>Implementation details (for maintainers)</b></summary>
106
-
107
- 1. **Before-capture** at `tools/execute` (the around-dispatch stage): the target file is read; the resolved path + content are held in a pending map. This stage runs only after any pre-execute approval gate let the call through — an `ask` short-circuit (dsh-edit-approval) **cannot skip** the backup, and a denied call never records. If the read fails (e.g. a permission error), the change is simply not backed up — the plugin warns in the log but **does not block the write**.
108
- 2. **Disk commit** at `tools/post-execute`: the before-backup is written under the turn's anchor message seq (`~/.dsh/rewind-snapshots/<session>/<anchor seq>/<callId>.json`).
109
- 3. **External-change tracking** (`reconcileTracked`): at each message boundary the plugin re-scans tracked files and records a `recheck-<anchor>-<hash>` entry anchored to the boundary message whenever the on-disk state differs from the last seen state; the first sighting of a path always records (the first boundary after a restart unconditionally records the current state — redundant but correct, mirroring Claude Code's resume-then-re-stat behavior).
110
- 4. **Restore** (`/rewind @<seq> both`): `planRestore` probes the disk per record — `before === null` (the file did not exist at the target) plans a delete only when the file currently exists (an absent file already matches); `before === 'X'` plans a restore only when the current content differs from X (identical content is a no-op, idempotent); a probe failure is treated conservatively as differing (never silently skipped). Execution: restore = create parent dir + write back content; delete = remove the file (an already-absent file is tolerated as a no-op); symlinks / hard links are skipped (they share an inode with another name; restoring through one would clobber both); failures are recorded per file and never abort the pass.
111
- 5. A tool body that **throws** skips `tools/post-execute`; a `tools/result` safety net clears the pending capture so nothing leaks in memory. Backups persist across host restarts; `prune` keeps the newest 100 anchor groups per session.
112
-
113
- </details>
97
+ | Design | Why it matters |
98
+ | --- | --- |
99
+ | A single append is a whole rewind | Minimal action, maximal semantics; the log is never mutated |
100
+ | Mask, never delete | History is always auditable and in principle recoverable |
101
+ | Before-backup, grouped by turn, persisted on disk | Space-efficient, survives restarts, Claude Code-aligned |
102
+ | Identical content stored as a link (dedup) | Hundreds of repeated writes cost almost nothing; links are materialized before their group is evicted, never left dangling |
103
+ | Session-level auto-cleanup | Removes only long-inactive sessions' snapshots; the active session and the chat log are never touched |
104
+ | Reconcile against the real disk before restoring | Idempotent, zero side effects, no collateral damage |
105
+ | Ghost step frame + reused turn number | Deeply compatible with the host, pinned by probe tests |
106
+ | Crash safety (atomic writes + restore journal) | Continue or roll back cleanly after a crash |
107
+ | Pure-function planning + probed store | Fully unit-testable without a host; test-driven |
114
108
 
115
109
  ## What it deliberately does NOT do
116
110
 
@@ -135,22 +129,24 @@ Third-party DOM plugins that need to know which transcript rows a rewind
135
129
  withdrew should consume the stable, locale-independent helpers exported from
136
130
  `dsh-rewind-plugin/client` — never parse
137
131
  `outcome.text`. The `data-dsh-rewind-hidden` attribute marks withdrawn rows
138
- (observational only). Details: [docs/client-contract.md](docs/client-contract.md).
132
+ (observational only). Details: [docs/contract/client-contract.md](docs/contract/client-contract.md).
139
133
 
140
134
  ## Known issues
141
135
 
142
136
  1. **Exported logs are complete** — a rewind only removes messages from the model context and the view; the exported session log (`/export`) still contains **withdrawn messages**. This plugin cannot alter exports.
143
- 2. **Rewinds from `≤ v0.2.4`**sessions rewound with these versions may **fail to load history** after more conversation. Install a v0.3.3-or-earlier release and use its bundled repair tool ([docs/troubleshooting.md](docs/troubleshooting.md)).
144
- 3. **Rewinds from `≤ v0.3.3`** — compaction (`/compact`) is unavailable for those sessions. Newer versions are compatible; for affected old sessions, start a new session.
137
+ 2. **Lightweight file rewind has a cost** in specific cases not all changes can be rewound. Consistent with Claude Code. See: [File-rewind tracking boundary](docs/compat/tracking-boundary.md).
138
+ 3. **Rewinds from `≤ v0.2.4`** — sessions rewound with these versions may **fail to load history** after more conversation. Install a v0.3.3-or-earlier release and use its bundled repair tool ([docs/compat/troubleshooting.md](docs/compat/troubleshooting.md)).
139
+ 4. **Rewinds from `≤ v0.3.3`** — compaction (`/compact`) is unavailable for those sessions. Newer versions are compatible; for affected old sessions, start a new session.
145
140
 
146
141
  ## Security
147
142
 
148
- This plugin only appends rewind-marker events to the session log; it never deletes or rewrites logged history. Workspace files are written only when you choose "conversation and code"; backups and restores stay under `~/.dsh/rewind-snapshots/`. It never touches your git repository, makes no network requests, and accesses no credentials. Delete `~/.dsh/rewind-snapshots/` to wipe file backups only (chat rewinds are unaffected); the plugin rebuilds automatically.
143
+ This plugin only appends rewind-marker events to the session log; it never deletes or rewrites logged history. Workspace files are written only when you choose "conversation and code"; backups and restores stay under `~/.dsh/rewind-snapshots/`. It never touches your git repository, makes no network requests, and accesses no credentials. Delete `~/.dsh/rewind-snapshots/` to wipe file backups only (chat rewinds are unaffected); the plugin rebuilds automatically. Full security model: [SECURITY.md](SECURITY.md).
149
144
 
150
145
  ## Development
151
146
 
152
147
  ```sh
153
148
  npm install # devDeps from the npm registry
149
+ npm run check # one-shot full gate: typecheck + test + build + verify:host + pack --dry-run
154
150
  npm run typecheck # tsc on all three surfaces (host + client + client-test)
155
151
  npm test # vitest: all unit and compatibility suites
156
152
  npm run build # esbuild: lib/index.js (host ESM) + lib/client.js (loader closure) + .d.ts
@@ -161,6 +157,8 @@ node scripts/verify-host.mjs # end-to-end verification of the built artifact
161
157
 
162
158
  Maintainers: the module map and harness interface reference live in [docs/harness-reference.md](docs/harness-reference.md).
163
159
 
160
+ Contributing guide: [CONTRIBUTING.md](CONTRIBUTING.md).
161
+
164
162
  ## Release
165
163
 
166
164
  Releases go out through GitHub Actions Trusted Publishing (OIDC, no stored `NPM_TOKEN`): push a `v<version>` tag and CI publishes with Sigstore provenance.
@@ -169,7 +167,7 @@ Releases go out through GitHub Actions Trusted Publishing (OIDC, no stored `NPM_
169
167
  npm version patch && git push origin main --tags
170
168
  ```
171
169
 
172
- One-time npm-side setup and the full workflow details: [docs/release.md](docs/release.md).
170
+ One-time npm-side setup and the full workflow details: [docs/release/release.md](docs/release/release.md).
173
171
 
174
172
  ## License
175
173
 
package/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  DeepSeek Harness 插件:**一键就地回退对话到任意更早的用户消息**——同窗口内完成,不新建分支、不换窗口,可一并还原工作区文件(完整 Claude Code `/rewind` 语义)。
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/dsh-rewind-plugin.svg)](https://www.npmjs.com/package/dsh-rewind-plugin)
6
- [![npm license](https://img.shields.io/npm/l/dsh-rewind-plugin.svg)](https://github.com/SiriLee/dsh-rewind/blob/main/LICENSE)
7
6
  [![npm downloads](https://img.shields.io/npm/dm/dsh-rewind-plugin.svg)](https://www.npmjs.com/package/dsh-rewind-plugin)
7
+ [![tests](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FSiriLee%2Fdb3b9260351c2b26eb3d201c2ed29df1%2Fraw%2Fbadge.json)](https://github.com/SiriLee/dsh-rewind/actions/workflows/ci.yml)
8
8
 
9
9
  > [English](README.en.md) | 中文
10
10
 
@@ -12,7 +12,7 @@ DeepSeek Harness 插件:**一键就地回退对话到任意更早的用户消
12
12
 
13
13
  - **回退 = 时间回溯**——目标消息及其之后的全部内容(agent 回复、工具调用)同时从**模型上下文**和**渲染对话**中撤回,不新建会话、不切换窗口;目标消息文本会回填输入框,改完可重发。**在原理上就真正无感、便捷**。
14
14
  - **轻量工作区备份**——行为对齐 Claude Code:只跟踪写文件的工具,写前做轻量备份并**落盘持久化**,不依赖、也不触碰 git 仓库。一个轻型插件,即拥有**完备的智能体回退能力**。
15
- - **信息安全优先**——插件从不删改会话日志(append-only),从不真正删除你的任何对话;文件还原限定在插件自己的备份目录。
15
+ - **信息安全优先**——插件从不删改会话日志(append-only),从不真正删除你的任何对话;文件还原限定在插件自己的备份目录。完整安全模型:[SECURITY.md](SECURITY.md)。
16
16
  - **完备测试系统**——单元、探针、端到端主机验证,覆盖兼容性探测、日志重放、续接、跨重启等场景;随 harness 升级持续维护,确保功能稳定。
17
17
 
18
18
  ## 效果预览
@@ -56,6 +56,12 @@ dsh plugin --profile web add dsh-rewind-plugin
56
56
 
57
57
  </details>
58
58
 
59
+ ## 存储管理
60
+
61
+ 快照(写前备份)存储于 `~/.dsh/rewind-snapshots/`。插件对**同一会话**的快照做内容去重(内容未变则存为链接)并保留最近 100 组锚点;**手动删除该目录**仅清除文件备份(对话回退不受影响),插件会自动重建。
62
+
63
+ 另提供**全局自动清理**(默认关闭):把**长期不活跃**的会话快照整目录移除,不影响活动会话与对话日志。用 `/snapshot-auto-cleanup` 命令**查看与设置**,配置写入 `~/.dsh/snapshot-cleanup.json`。详见:[快照自动清理](docs/snapshot-auto-cleanup.zh.md)。
64
+
59
65
  ## 本插件的优势
60
66
 
61
67
  和常见的几种做法相比,本插件在"回退"这件事上的取舍:
@@ -69,48 +75,42 @@ dsh plugin --profile web add dsh-rewind-plugin
69
75
 
70
76
  ## 原理
71
77
 
72
- 机制与 Claude Code 的 checkpointing 同源(Claude Code 的文件历史也是逐文件记录 + 每条消息重扫已跟踪文件,并非整树快照)。本插件实现的是同一套语义,落在 dsh 上:
73
-
74
- ### 1. 对话回退:怎么做到"就地"又不丢日志
75
-
76
- 对话日志是**只追加**的——插件绝不改写历史。回退的做法:往日志里追加一条**空内容标记**,用它把目标消息之后的全部对话内容"遮蔽"掉,让模型和界面都只看到目标之前的部分:
77
-
78
- - 标记本身是**空的**——不进入模型上下文、不渲染成任何对话内容,agent 和你看到的对话就是目标消息当时的样子;
79
- - 因为只是"遮蔽"而非"删除",**被撤回的每一条事件都完整留在日志里**,审计可追溯;
80
- - 标记复用"最后一个已开始的回合"的编号,并自带独立的步骤框架——让 dsh 自身的机制(日志重放、`/compact` 压缩、续接检查)都能正确识别它,不会误认为真实对话(这些兼容细节经过专门的探针测试固化,见[开发](#开发)一节)。
78
+ 整套设计只有两条主线,核心哲学朴素却克制:**对话部分「只遮蔽、不删除」,文件部分「改前先备份,还原时对照真实磁盘」**。机制与 Claude Code 的 checkpointing 同源——Claude Code 的文件历史也是逐文件记录 + 每条消息重扫已跟踪文件,并非整树快照;本插件把同一套语义落在 dsh 上,并做得更轻、更稳。
81
79
 
82
- <details>
83
- <summary><b>实现细节(给维护者)</b></summary>
84
-
85
- 插件向会话日志追加一条空内容标记 `assistant/message`,其 `surfaceOp: { op: 'replace', start, end }` 把目标消息之后的全部 surface 节点替换为标记本身:
80
+ ### 1. 对话回退:一次「遮蔽」,而不是「删除」
86
81
 
87
- - 标记携带 `sourceEventSeqs` 覆盖所有被遮蔽节点,`Session.append` 的 surface 规则校验切割合法性(仅限当前 surface 上的连续区间)。
88
- - 因为标记**内容为空**,harness 会将其派生为 `null`——永不进入模型上下文、也永不渲染成对话内容。
89
- - 标记的 **turn 号复用最后一个已开始的回合**(`markerTurnOf`),而不是「最后回合 + 1」:harness 恰好用 `最后 turn/start + 1` 编号下一条真实回合。若标记也取这个数,日志里就会出现同一 turn 的 `assistant/message` 先于 `turn/start` 的乱序,客户端 conversation 构建器会以 `conversation Context …:turn-tail… received an update before its start Match` 拒绝重放——历史加载失败、整个对话从界面消失。复用已消费的 turn 号则标记只是上一个已完成回合尾部的一次无害追加,永不与新回合冲突。
90
- - 标记自带**幽灵步骤框架**——自己的 `step/start` … `step/end`,step 号取该回合未用过的新号(`markerStepOf`):harness 的 token-meter 重放要求每条 `assistant/message` 位于打开的 step 内,裸标记则会让该会话的 `/compact` 失效。
82
+ `append-only` 是铁律:会话日志只追加、从不改写——这是可审计与信息安全的地基。回退从不动历史,它只做一步:往日志末尾追加**一条内容为空的消息标记**,用它把目标消息之后的全部内容「遮蔽」掉,让模型和界面都只看得到目标之前的部分。
91
83
 
92
- </details>
84
+ - 标记本身是**空的**——不进入模型上下文、不渲染成任何对话内容,模型和你看到的对话就是目标消息当时的样子,真正的「就地」;
85
+ - 因为是「遮蔽」而非「删除」,**被撤回的每一条事件都完整留在日志里**,可审计、可追溯,原则上也随时能手动恢复;
86
+ - 标记非常「懂」dsh——它复用**最后一个已开始的回合**的编号(而不是「最后回合 + 1」),并自带一个独立的**幽灵步骤框架**。于是 harness 自己的日志重放、`/compact` 压缩、续接检查都能正确识别它,绝不会把它误认为真实对话。
93
87
 
94
- agent 正在运行(LLM 思考/流式输出),会先强制停止并等待安静下来再回退;停不下来则中止并报错。
88
+ > **设计点睛**:整个对话回退就是**一条**追加。它确定、可审计,且因为日志从未被破坏,回溯是「干净的」——用最小的动作,实现最完整的语义。那些与 harness 内部的兼容细节(幽灵步骤框架、复用回合号)正是插件的专业所在,每一条都由专门的探针测试固化。
95
89
 
96
- ### 2. 文件还原:写前备份 + 外部变更追踪 + 磁盘比对
90
+ ### 2. 文件还原:轻量检查点,「改前备份」
97
91
 
98
- 插件跟踪写文件的工具——`write`、`edit`、`str_replace_editor`:
92
+ 文件部分对齐 Claude Code 的检查点语义——**逐文件、写前备份 + 每条消息重扫已跟踪文件**,而不是整树快照。这项取舍既省空间,又更完整:
99
93
 
100
- 1. **写前备份**:每次写文件**之前**先把原内容存下来(在审批门放行之后捕获——审批短路不会漏备份,被拒绝的调用也不会记录;读取失败只警告、不阻塞写操作)。备份按当前对话轮次分组,**跨重启保留**(每会话保留最近 100 组)。
101
- 2. **外部变更也追踪**:每条用户消息边界会重新检查所有已跟踪文件——外部编辑或删除(写工具从没参与过的改动)同样被记录下来,回退时一并还原。
102
- 3. **还原前真实磁盘比对**:回退时实时读取文件当前内容,与目标状态比对——**只操作真正不一致的文件**:改过的写回最早备份、目标之后新建的删除、已一致的一律跳过(重复回退零副作用)。符号/硬链接跳过,避免误伤。
94
+ - **改前备份**:追踪写类工具(`write`、`edit`、`str_replace_editor`),在**每次写文件之前**先把原内容存下来。关键在时机——在审批门放行之后捕获:审批短路不会漏备份,被拒绝的调用也不会留下记录;读取失败只警告、从不阻塞写操作。备份按对话轮次分组锚定,**落盘持久化**,重启也还在。
95
+ - **外部变更也追**:每条用户消息边界,插件重新检查所有已跟踪文件——那些从没经过写工具、被外部改过或删掉的文件,同样被记录,回退时一并还原。这让「轻量」却不「残缺」。
96
+ - **还原前对照真实磁盘**:这是最值得说的一点。回退时插件实时读取文件当前内容,与目标状态逐一比对——**只操作真正不一致的文件**:改过的写回最早备份、目标之后新建的删除、已经一致的跳过。重复回退因此**零副作用、幂等**,绝不会出现「幽灵影响」。
97
+ - **安全边界**:符号/硬链接跳过,避免透过一个还原误伤另一个名字;路径经安全化处理,**绝不越出备份根目录**;单个文件失败绝不中止整轮还原。
103
98
 
104
- <details>
105
- <summary><b>实现细节(给维护者)</b></summary>
99
+ > **设计点睛**:**「对照真实磁盘再动手」** 是这套检查点里最有洞察力的决定——它从不盲目假设,而是以磁盘为准,该做的做、不该做的跳过。
106
100
 
107
- 1. **写前备份**(`tools/execute`,around-dispatch 阶段):读取目标文件,把解析后的路径与内容放入 pending 表。此阶段只在任何 pre-execute 审批门放行之后运行——审批 `ask` 短路(dsh-edit-approval)**无法跳过**备份,被拒绝的调用也不会记录。若读取失败(如权限错误),该次变更直接不入备份——插件只在日志中警告,**不会阻塞写操作**。
108
- 2. **落盘提交**(`tools/post-execute`):备份按当前轮**锚点消息 seq** 写入 `~/.dsh/rewind-snapshots/<会话>/<锚点 seq>/<callId>.json`。
109
- 3. **外部变更追踪**(`reconcileTracked`):消息边界处重扫已跟踪文件,磁盘状态与上次记录不同则记录一条 `recheck-<锚点>-<hash>` 条目(锚定边界消息);首见路径必记(重启后第一次边界无条件记录当前状态——冗余但正确,对应 Claude Code 的 resume 后 restat 行为)。
110
- 4. **还原**(`/rewind @<seq> both`):`planRestore` 对每条记录实时探测磁盘——`before === null`(目标时不存在)仅当文件当前存在才计划删除(已缺失即已达成);`before === 'X'` 仅当当前内容 ≠ X 才计划还原(一致即 no-op,幂等);探测失败保守视为不一致(绝不静默跳过)。执行时 restore = 建父目录 + 写回内容,delete = 删文件(已不存在容忍为 no-op);符号/硬链接跳过(与另一名字共享 inode,透过一个还原会误伤两个);失败逐文件记录、不中止整轮。
111
- 5. 工具体**抛异常**会跳过 `tools/post-execute`;`tools/result` 兜底清掉 pending,避免内存泄漏。备份跨 host 重启持久化,`prune` 每会话有界保留最近 100 组锚点。
101
+ ### 设计亮点一览
112
102
 
113
- </details>
103
+ | 设计 | 为什么值得 |
104
+ | --- | --- |
105
+ | 一次追加即一次回退 | 极小动作、极大语义,且日志从不被破坏 |
106
+ | 只遮蔽、不删除 | 历史永远可审计,原则上可恢复 |
107
+ | 改前备份 + 按轮分组 + 落盘 | 省空间、跨重启、对齐 Claude Code |
108
+ | 同内容存为链接(去重) | 上百次重复写入几乎不占空间;淘汰组前先落地链接,绝不悬空 |
109
+ | 会话级自动清理 | 只移除长期不活跃会话的快照,活动会话与对话日志永不触及 |
110
+ | 对照真实磁盘再还原 | 幂等、零副作用、不误伤 |
111
+ | 幽灵步骤框架 + 复用回合号 | 与宿主深度兼容,且被探针测试固化 |
112
+ | 崩溃安全(原子写 + 还原日志) | 断电/崩溃后仍可续做或回滚 |
113
+ | 纯函数规划 + 注入探针的存储 | 无需宿主即可单测,测试驱动 |
114
114
 
115
115
  ## 明确不做的事
116
116
 
@@ -132,22 +132,24 @@ dsh plugin --profile web add dsh-rewind-plugin
132
132
  ## 客户端契约
133
133
 
134
134
  需要获知哪些转录行被回退撤回的第三方 DOM 插件,应使用 `dsh-rewind-plugin/client` 导出的稳定、与本地化无关的纯函数,切勿解析 `outcome.text`。`data-dsh-rewind-hidden` 属性标记被撤回的行(仅观测性)。
135
- 详见:[docs/client-contract.zh.md](docs/client-contract.zh.md)。
135
+ 详见:[docs/contract/client-contract.zh.md](docs/contract/client-contract.zh.md)。
136
136
 
137
137
  ## 已知问题
138
138
 
139
139
  1. **导出的日志是完整内容**——回退只是把消息从模型上下文和视图中移除,`/export` 导出的会话日志包含**已撤回的消息**。本插件无法改动导出。
140
- 2. **v0.2.4 及更早版本**回退过的会话,继续对话后可能加载历史失败。可安装 v0.3.3 及之前版本的随附修复工具处理([完整步骤](docs/troubleshooting.zh.md))。
141
- 3. **v0.3.3 及更早版本**回退过的会话,压缩对话(compact)不可用。新版本已兼容;受影响的旧会话建议新建会话。
140
+ 2. **轻量文件回退存在代价**——特定情况可能无法回退所有修改。行为与 Claude Code 一致。详见:[文件回退的追踪边界](docs/compat/tracking-boundary.zh.md)
141
+ 3. **v0.2.4 及更早版本**回退过的会话,继续对话后可能加载历史失败。可安装 v0.3.3 及之前版本的随附修复工具处理([完整步骤](docs/compat/troubleshooting.zh.md))。
142
+ 4. **v0.3.3 及更早版本**回退过的会话,压缩对话(compact)不可用。新版本已兼容;受影响的旧会话建议新建会话。
142
143
 
143
144
  ## 安全
144
145
 
145
- 本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。工作区文件仅在「回退对话和代码」时被改写,备份与还原都限定在 `~/.dsh/rewind-snapshots/` 内。不触碰你的 git 仓库,无网络请求,不访问任何凭据。删除 `~/.dsh/rewind-snapshots/` 仅清除文件备份(对话回退不受影响),插件会自动重建。
146
+ 本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。工作区文件仅在「回退对话和代码」时被改写,备份与还原都限定在 `~/.dsh/rewind-snapshots/` 内。不触碰你的 git 仓库,无网络请求,不访问任何凭据。对**长期不活跃**的会话,另有默认关闭的全局自动清理可整目录移除其快照,不影响活动会话与对话日志。完整安全模型:[SECURITY.md](SECURITY.md)。
146
147
 
147
148
  ## 开发
148
149
 
149
150
  ```sh
150
151
  npm install # devDeps 来自 npm registry
152
+ npm run check # 一键全检:typecheck + test + build + verify:host + pack --dry-run
151
153
  npm run typecheck # tsc 三面编译(host + client + client-test)
152
154
  npm test # vitest:全部单元与兼容性测试套件
153
155
  npm run build # esbuild:lib/index.js(host ESM)+ lib/client.js(loader 闭包)+ .d.ts
@@ -158,6 +160,8 @@ node scripts/verify-host.mjs # 端到端验证构建产物
158
160
 
159
161
  维护者:模块地图与 harness 接口参考见 [docs/harness-reference.md](docs/harness-reference.md)
160
162
 
163
+ 贡献指南:[CONTRIBUTING.md](CONTRIBUTING.md)
164
+
161
165
  ## 发布
162
166
 
163
167
  通过 GitHub Actions Trusted Publishing(OIDC,无存储 `NPM_TOKEN`)发布:推送 `v<版本>` tag,CI 即带 Sigstore provenance 发布。
@@ -166,7 +170,7 @@ node scripts/verify-host.mjs # 端到端验证构建产物
166
170
  npm version patch && git push origin main --tags
167
171
  ```
168
172
 
169
- 一次性 npm 侧配置与完整流程:见 [docs/release.md](docs/release.md)。
173
+ 一次性 npm 侧配置与完整流程:见 [docs/release/release.zh.md](docs/release/release.zh.md)。
170
174
 
171
175
  ## 许可
172
176
 
package/SECURITY.md ADDED
@@ -0,0 +1,154 @@
1
+ # Security model
2
+
3
+ This document is the security model of **dsh-rewind** — how the plugin treats
4
+ untrusted input, what it is allowed to mutate, and how it survives crashes.
5
+ It is derived from the implementation (`src/index.ts`, `src/snapshot.ts`); if
6
+ this document and the code ever disagree, the code wins and this document is
7
+ a bug.
8
+
9
+ ## Trusted boundary
10
+
11
+ The plugin runs in the DSH host process and therefore holds the host user's
12
+ filesystem authority — it reads and writes files with plain `node:fs`. The
13
+ following are treated as **untrusted inputs**:
14
+
15
+ - **Model arguments** — the `file_path` / `path` / `command` fields of write,
16
+ `edit`, and `str_replace_editor` tool calls (they name the paths the
17
+ checkpoint store records).
18
+ - **Session log contents** — events are parsed structurally; a hostile or
19
+ malformed id must never escape the store root.
20
+ - **Current worktree state** — restore planning reconciles against the live
21
+ disk, which may have been changed by anything.
22
+ - **Concurrent external modifications** — a restore never assumes the disk
23
+ still matches its records.
24
+
25
+ The DSH host (and its other plugins) is trusted; this plugin does not
26
+ re-verify the host's own authority boundaries.
27
+
28
+ ## Mutation gates
29
+
30
+ The plugin **automatically captures** before-backups of tracked mutations, but
31
+ **never automatically applies** one. A workspace restore happens only through
32
+ an explicit user-invoked `/rewind @<seq> both` (the per-message ↶ button or the
33
+ command channel), and only when all of the following hold:
34
+
35
+ 1. **A validated target**: `planRewind` accepts only a `user/message` seq that
36
+ is currently on the session surface (`parseRewindTarget` → `RewindPlan`).
37
+ 2. **A fresh plan**: the plan is derived from the current `events` + `surface`
38
+ at execution time — never cached across events.
39
+ 3. **Committed backups exist**: every planned action comes from a committed
40
+ checkpoint entry anchored at or after the target.
41
+ 4. **Live-disk reconciliation**: `planRestore` compares each entry against the
42
+ current disk and plans only actions that would actually change it — an
43
+ already-matching state is a no-op (restores are idempotent).
44
+ 5. **Exclusive execution**: a per-session in-flight guard rejects concurrent
45
+ rewinds; a running turn is force-cancelled (`keepInbox`) and quiescence is
46
+ awaited before the surface is cut.
47
+ 6. **Session binding**: the restore reads/writes only the store of the rewound
48
+ session (paths are resolved display paths the session's own tools touched).
49
+
50
+ **Concurrency scope**: the in-flight guard above only serializes concurrent
51
+ *rewinds* of one session; it does not stop other processes from modifying the
52
+ workspace. `planRestore` reconciles against the live disk at plan time, but
53
+ there is no re-validation between planning and applying — a file changed by
54
+ another process in that window is overwritten by the restore. The rescue state
55
+ is captured at apply start, so `rollbackRestore` still returns the workspace
56
+ to exactly the apply-start state, and per-file failures are reported rather
57
+ than hidden.
58
+
59
+ A failed gate fails closed: an invalid target, a missing store, an absent
60
+ backup, or a cancelled invocation aborts the rewind with an error.
61
+
62
+ **Automatic store deletion (opt-in)**: unrelated to restores, an enabled
63
+ `snapshot-auto-cleanup` background sweep deletes whole session directories of
64
+ **long-inactive** sessions (default off — the store is otherwise only mutated by
65
+ an explicit rewind apply). It is confined to the store root, uses `lstat` (so it
66
+ never follows a symlink out of the root), skips the active session, and never
67
+ touches the conversation log. When disabled (the default), no automatic deletion
68
+ runs.
69
+
70
+ ## Conversation integrity
71
+
72
+ The session log is **append-only** — the plugin never deletes or rewrites
73
+ recorded history. A rewind appends a single marker event: an **empty**
74
+ `assistant/message` whose `surfaceOp` replaces every surface node after the
75
+ target. The raw log (audit trail, search, `/export`) is untouched — only the
76
+ model-visible surface is cut, so the next request derives its context from the
77
+ target onward. The marker sits inside a ghost `step/start … step/end` frame so
78
+ the harness token-meter replay keeps accepting the log; a malformed marker
79
+ (duplicated turn/step, dangling open step) is what earlier versions produced
80
+ and is repaired offline (see `docs/compat/troubleshooting.md`).
81
+
82
+ ## Filesystem containment
83
+
84
+ - **Store root**: `~/.dsh/rewind-snapshots/` by default (`DSH_REWIND_SNAPSHOT_DIR`
85
+ overrides it). The store never overlaps the workspace; deleting it only
86
+ removes file backups and the store rebuilds from scratch.
87
+ - **Path sanitization**: session ids and call ids are scrubbed to
88
+ `[a-zA-Z0-9._-]` (`safeSessionId` / `safeFileId`) before joining the store
89
+ root; `.` and `..` bare values are replaced — hostile ids cannot traverse
90
+ out of the root.
91
+ - **Never written through links**: symlinked and hard-linked paths
92
+ (`lstat().nlink > 1`) are skipped and reported, never restored — a symlink
93
+ would redirect the write outside the checkpoint, and a hard link would
94
+ clobber every other name of the same inode (e.g. pnpm-installed files).
95
+ - **Restores name only recorded paths**: the store contains resolved display
96
+ paths of the session's own write-class tool calls (plus boundary re-checks
97
+ over that same tracked set) — a restore can never write an arbitrary path.
98
+ - **Path resolution rule**: relative paths resolve against the session
99
+ workspace cwd, mirroring the fs tools' own rule (`src/session-cwd.ts`).
100
+ - **Bounded backups**: `prune` keeps the newest 100 anchor groups per session
101
+ (`MAX_ANCHOR_GROUPS`), so backup accumulation cannot grow the store without
102
+ bound (the exact cap is pinned in `docs/format.md`). Across sessions, the
103
+ opt-in `pruneStale` sweep removes whole **long-inactive** session directories
104
+ (measured by the newest member being idle past `maxAgeDays`); it uses `lstat`
105
+ (no symlink following), skips dot-prefixed temp files, and never targets the
106
+ active session (`keepActiveId`).
107
+
108
+ ## Crash safety
109
+
110
+ - **Atomic commits**: every JSON write (checkpoint entries, restore journals)
111
+ goes to a sibling temp file and is renamed over the target. A host crash
112
+ mid-write can leave only an inert `<target>.tmp` — never a readable
113
+ half-written file — and readers never pick up temp files.
114
+ - **Journaled restores**: before mutating anything, the restore captures each
115
+ planned path's pre-restore ("rescue") state and persists an intent journal,
116
+ then marks each action done as it is applied. A crash at any point leaves
117
+ the journal on disk.
118
+ - **Disk is truth**: after a restart, `reconcileRestores` re-derives from the
119
+ real disk which paths already match the goal (restored) and which are
120
+ pending; `continueRestore` finishes the interrupted op, `rollbackRestore`
121
+ undoes it to the exact pre-restore state. A journal whose goal is already
122
+ reached auto-heals to its terminal state.
123
+ - **Fail-loud vs fail-soft**: a corrupt **journal** is reported
124
+ `recovery-required` — never silently dropped (dropping it would erase the
125
+ interrupted restore's recovery record). Corrupt **checkpoint entries** are
126
+ silently ignored (they only lose one backup, not the recovery path).
127
+ - **Journal IO never fails the restore**: if the journal cannot be written the
128
+ restore proceeds with pre-journal semantics (crash safety degrades,
129
+ behavior does not).
130
+
131
+ ## Explicit non-goals
132
+
133
+ - This plugin does **not** sandbox other processes or stop them from changing
134
+ files concurrently.
135
+ - It does **not** provide confidentiality or tamper resistance against the
136
+ same operating-system user: state files are created with the process
137
+ default permissions (no special modes are set — a standard umask applies),
138
+ and the host user remains trusted.
139
+ - It does **not** touch git (no refs, index, or worktree operations), makes
140
+ **no network requests**, and does **not** access credentials.
141
+ - It does **not** roll back whole-log state: telemetry, search, and `/export`
142
+ still see the withdrawn messages (documented behavior, not a bug).
143
+ - It does **not** restore files written by a cancelled tool call that never
144
+ committed a backup.
145
+ - **Subagent session edits are not tracked** (Claude Code alignment): a
146
+ subagent runs its own session, so the files it changes are not backed up and
147
+ cannot be restored by a rewind of the parent session.
148
+
149
+ ## Reporting
150
+
151
+ Report a vulnerability through the repository's GitHub security channel or to
152
+ a repository maintainer. Include: the plugin version/commit, the DSH
153
+ (`@deepseek-ai/*`) version, the platform, and a minimal reproduction — and
154
+ whether the failure happened before or after workspace mutation.
package/docs/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # Docs
2
+
3
+ Documentation lives under `docs/`, organized by concern. Everything here is
4
+ maintainer-facing unless the purpose column says otherwise. This file is the
5
+ index/navigation entry point.
6
+
7
+ ## Index
8
+
9
+ | Path | Purpose | Audience |
10
+ | --- | --- | --- |
11
+ | `architecture.md` | Module layering, rewind/checkpoint pipelines, compatibility strategy, roadmap | maintainers |
12
+ | `format.md` | Durable on-disk format spec (checkpoint entries + restore journals) | maintainers |
13
+ | `harness-reference.md` | DeepSeek Harness interface reference + plugin source layout | maintainers |
14
+ | `contract/client-contract.md` | Rewind visibility contract for third-party DOM plugins (`.zh` mirror) | integrators |
15
+ | `compat/audit.md` | Compatibility audit: verified surfaces, recorded findings, probe matrix | maintainers |
16
+ | `compat/troubleshooting.md` | Known issues & repair steps (`.zh` mirror) | users / maintainers |
17
+ | `release/release.md` | Release workflow & DSH peer-version alignment (`.zh` mirror) | maintainers |
18
+
19
+ Repo-root docs outside `docs/`: `SECURITY.md` (security model) and
20
+ `CONTRIBUTING.md` (contribution guide).
21
+
22
+ ## Conventions
23
+
24
+ - Bilingual docs use the `.md` / `.zh.md` file split (e.g. `contract/client-contract.md`
25
+ + `contract/client-contract.zh.md`, `release/release.md` + `release/release.zh.md`).
26
+ - `compat/audit.md` is the single source of truth for compatibility conclusions;
27
+ other docs link to it instead of restating them.
28
+ - Cross-links are relative so the whole `docs/` directory stays relocatable — and it
29
+ ships intact in the npm tarball via the `docs` entry in `files` (`package.json`).