dsh-rewind-plugin 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONTRIBUTING.md CHANGED
@@ -58,9 +58,10 @@ npm run verify:host # end-to-end host verification (full check suite)
58
58
  ## Testing expectations
59
59
 
60
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`).
61
+ - Store behavior (`snapshot.ts`) → `tests/snapshot.test.ts` plus the focused
62
+ suites (`snapshot-bytes`, `snapshot-mode`, `downgrade-safety`,
63
+ `parent-guard`), and crash-safety scenarios in `tests/crash-safety.test.ts`
64
+ via the test-only `crash` seam (`RestoreRunOptions.crash`).
64
65
  - Harness interaction → the compatibility suites
65
66
  (`compat-invariants` / `compat-interop` / `compat-gaps`) and
66
67
  `scripts/verify-host.mjs`.
package/README.en.md CHANGED
@@ -47,8 +47,8 @@ dsh plugin --profile web add dsh-rewind-plugin@<version>
47
47
  ## Usage
48
48
 
49
49
  1. Find the user message you want to rewind to in the conversation, or type `/rewind` (or its alias `/undo`) to open the candidate picker.
50
- 2. **Select it.** A small popover offers the two modes ("conversation and code" is only shown when there are restorable changes after the target).
51
- 3. The rewind takes effect immediately: the conversation returns to how it looked at the target message, and the withdrawn message's text is filled back into the composer — edit and re-send.
50
+ 2. **Select it.** A small popover offers the two modes "conversation only" or "conversation and code".
51
+ 3. The rewind takes effect immediately: the conversation returns to how it looked at the target message, and the target message's text is filled back into the composer — edit and re-send.
52
52
 
53
53
  **Keyboard**: both the candidate picker and the mode popover support ↑↓ to move, Enter to confirm, Esc to cancel/back.
54
54
 
@@ -56,20 +56,19 @@ dsh plugin --profile web add dsh-rewind-plugin@<version>
56
56
  <summary><b>Edge notes</b></summary>
57
57
 
58
58
  - Rewinds can be repeated — with no limit on stage or count.
59
- - A rewind itself **cannot be undone**, but the withdrawn content stays in the session log and can be recovered by manually editing it.
59
+ - A rewind itself **cannot be undone**, but the withdrawn content stays in the session log.
60
60
  - **Interruptions rewind too** — a `steering` interruption message the model hasn't read yet is also a valid rewind target.
61
- - **A rewind interrupts the running turn** — to execute the rewind safely.
61
+ - **A rewind interrupts the running turn** — to ensure the rewind runs safely.
62
62
 
63
63
  </details>
64
64
 
65
65
  ## Snapshot management
66
66
 
67
67
  Snapshots (the before-write backups) are stored under `<dsh home>/rewind-snapshots/`
68
- (`~/.dsh/rewind-snapshots/` when `$DSH_HOME` is unset). For the **same session**,
69
- the plugin deduplicates snapshots by content (an unchanged file is stored as a link)
70
- and keeps the newest 100 anchor groups. **Deleting that directory manually** only
71
- clears the file backups (chat rewinds are unaffected) and the plugin rebuilds them
72
- automatically.
68
+ (the default is `~/.dsh/rewind-snapshots/`). For the **same session**, the plugin
69
+ deduplicates snapshots by content and keeps the newest 100 anchor groups.
70
+ **Deleting that directory manually** only clears the file backups (chat rewinds are
71
+ unaffected) and the plugin rebuilds them automatically.
73
72
 
74
73
  A **global auto-cleanup** (off by default) removes the snapshot directories of
75
74
  long-inactive sessions, leaving the active session and chat log untouched. Configure
@@ -101,57 +100,44 @@ Compared with the common approaches, here is the trade-off this plugin makes on
101
100
  | Dimension | Common approach | This plugin |
102
101
  | --- | --- | --- |
103
102
  | Conversation rewind | Fork / branch a new conversation | **In-place rewind** — no new session, no window switch |
104
- | File restore | No restore feature / git-managed or whole-tree snapshot | **Lightweight before-backups** — auto-captured before writes, one-click restore (aligned with Claude Code) |
103
+ | File restore | No restore feature / git-managed or whole-tree snapshot | **Lightweight before-backups** — auto-captured before writes, one-click restore |
105
104
  | Dependencies | Often needs a Git repo or a full snapshot engine | **None** — no git required, works on any directory |
106
- | Storage footprint | Whole-tree snapshots take space | **Lightweight** — only files touched by write tools are stored, persisted on disk |
105
+ | Storage footprint | Whole-tree snapshots take space | **Lightweight** — nothing is stored unless it changed, and only files touched by write tools are tracked |
107
106
 
108
107
  ## How it works
109
108
 
110
- 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.
109
+ The whole design rests on two principles, simple but deliberate: **the conversation half "masks, never deletes"**, using DSH's native "hide + replace" mechanism; **the file half "partial tracking, lightweight before-write backup"**, following Claude Code's checkpoint semantics.
111
110
 
112
111
  ### 1. Conversation rewind: a single "mask", not a delete
113
112
 
114
- `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 content-empty marker message** to the end of the log and use it to "mask + replace" everything after the target message, so the model and the UI see only the part before it.
113
+ `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 message" marker** to the end of the log and use it to "mask + replace" everything after the target message, so the model and the UI see only the part before it.
115
114
 
116
- - The marker is **canonical** the plugin **replicates** `/compact`'s "hide + replace": `/compact` compresses a span of history into a summary, while `/rewind` swaps in an empty marker message. Because it's canonical, the harness's own log replay, `/compact` compaction, and resume preflight all recognize it and never mistake it for a real message.
117
- - The replacement is **imperceptible** — the model ignores the empty marker, with no effect (verified empirically). Together with the plugin's UI handling, what you and the model see is exactly how the conversation looked at the target — truly "in place".
118
- - Because this is **masking, not deleting**, every withdrawn event stays in the log auditable, traceable, viewable, and in principle manually recoverable.
115
+ - **One and the same log** the append happens only in the current session's log: no new session, no new branch, so no residue or copy is left behind;
116
+ - **The marker is canonical** — the same "hide + replace" as the official `/compact`: `/compact` compresses a span of history into a summary, while `/rewind` swaps in an "empty message" marker. Because it is canonical, DSH's log replay, compaction, and resume preflight all recognize it and never mistake it for a real message;
117
+ - **The replacement is imperceptible** — the model ignores the marker, with no effect (verified empirically). Together with the plugin's UI handling, what you and the model see is exactly how the conversation looked at the target;
118
+ - **Content is preserved** — because this is "masking, not deleting", the withdrawn content stays in the log — auditable, traceable, and in principle manually recoverable.
119
119
 
120
- > **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 (replicating `/compact`, the empty-message mask) are where this plugin is genuinely professional — each is pinned by a dedicated probe test.
120
+ > **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 DSH (replicating `/compact`, the empty-message mask) are where this plugin is genuinely professional.
121
121
 
122
- ### 2. File restore: lightweight checkpointing, "back up before the change"
122
+ ### 2. File restore: lightweight checkpointing, "before-write backup"
123
123
 
124
124
  The file half follows Claude Code's checkpoint semantics — **partial tracking + before-write backup, 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:
125
125
 
126
126
  - **Before-write backup**: tracks only the write-class tools (`write`, `edit`) — backs up the original content before a write and records/tracks the files it touches; it never backs up the whole workspace, so it's lightweight.
127
127
  - **External changes count too**: at every user-message boundary the plugin re-checks all tracked files — external changes such as a command run or a manual edit are recorded as well and restored by a later rewind. "Lightweight" but not "incomplete".
128
- - **Unchanged-not-recorded, identical-content-as-link**: an entry is written only when something changed — at the message-boundary re-check, an unchanged file is never backed up (no record); at before-write time, when the new content matches the path's prior record, only a **link to it** (`ref`) is stored instead of a copy. Repeated writes cost almost nothing, and a link is materialized before its group is evicted — never left dangling.
129
- - **Reconcile against the real disk before restoring**: restore takes each path's **earliest** record, then reads the live file and compares — **only files that actually differ are touched**: modified files are written back to the earliest backup, files created after the target are deleted, already-matching files are skipped. Repeated rewinds are therefore **idempotent with zero side effects** and never produce "ghost impact".
130
- - **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.
131
-
132
- > **Design highlight**: this checkpoint's light footprint comes from **recording only what was actually touched and really changed** — before-write backup makes it restorable, unchanged-not-recorded and content-as-link drop the repetition; which files to touch is decided against the real disk at restore time.
133
-
134
- ### Design highlights
135
-
136
- | Design | Why it matters |
137
- | --- | --- |
138
- | A single append is a whole rewind | Minimal action, maximal semantics; the log is never mutated |
139
- | Mask, never delete | History is always auditable and in principle recoverable |
140
- | Before-backup, grouped by turn, persisted on disk | Space-efficient, survives restarts, Claude Code-aligned |
141
- | 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 |
142
- | Session-level auto-cleanup | Removes only long-inactive sessions' snapshots; the active session and the chat log are never touched |
143
- | Reconcile against the real disk before restoring | Idempotent, zero side effects, no collateral damage |
144
- | Empty-message mask + replicating `/compact` | Deeply compatible with the host, pinned by probe tests |
145
- | Crash safety (atomic writes + restore journal) | Continue or roll back cleanly after a crash |
146
- | Pure-function planning + probed store | Fully unit-testable without a host; test-driven |
128
+ - **Unchanged-not-recorded**: an entry is written only when something changed — at the message-boundary re-check, an unchanged file is never backed up (no record); at before-write time, when the new content matches the path's prior record, only a **link to it** (`ref`) is stored instead of a copy.
129
+ - **Accurate restore**: backups are the sole standard, checked against the real disk — **only files that actually differ are touched**: modified files restored, newly created files deleted, deleted files recovered. Backups are stored byte for byte, so the restored result matches the backups exactly, with no "ghost impact".
130
+ - **Safety and integrity**: paths are sanitized so nothing ever escapes the backup root; symlinks / hard links are skipped so one restore can't clobber another name of the same file; a per-file failure never aborts the pass; backups and the restore journal are written atomically and kept across restarts, so a half-applied restore after a crash can be continued or rolled back.
131
+
132
+ > **Design highlight**: this checkpoint's light footprint comes from **recording only what was actually touched and really changed** — before-write backup makes it restorable, unchanged-not-recorded and content-as-link drop the repetition; only the files that differ are touched at restore time.
147
133
 
148
134
  ## What it deliberately does NOT do
149
135
 
150
136
  This plugin deliberately stays lightweight and focused on one thing — "conversation rewind". The following are **out of its scope**:
151
137
 
152
- - **Whole-tree / Git-level snapshots** — only write-class tool edits plus external changes to already-tracked files are backed up; files never touched by a tool are not restored. For whole-worktree snapshot rollback, use a dedicated snapshot tool (or your git).
153
- - **Subagent edits** — not tracked (same as Claude Code): a subagent runs its own session, so its backups could never be restored by a rewind of the parent session.
154
- - **Fork / branch rewind** — the harness already provides this ("branch in new chat"); no need to reinvent it.
138
+ - **Whole-tree / Git-level snapshots** — only write-class tool edits plus external changes to already-tracked files are backed up; files never touched by a tool are not restored. For a worktree-level full snapshot rollback, use a more specialized snapshot tool (git).
139
+ - **Subagent edits** — not tracked, and no rewind inside a subagent session (same as Claude Code): a subagent runs its own session, so its backups could never be restored by a rewind of the parent session, and none are kept for one.
140
+ - **Fork / branch rewind** — DSH already provides this ("branch in new chat"); no need to reinvent the wheel.
155
141
 
156
142
  ## Compatibility
157
143
 
@@ -183,7 +169,7 @@ withdrew should consume the stable, locale-independent helpers exported from
183
169
 
184
170
  ## Security
185
171
 
186
- 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 are stored under `<dsh home>/rewind-snapshots/`; restores draw only from those backups. 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. For sessions you've left inactive for a long time, a global auto-cleanup (off by default) can remove their snapshot directory in whole, leaving the active session and the chat log untouched. Full security model: [SECURITY.md](SECURITY.md).
172
+ 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 are stored under `<dsh home>/rewind-snapshots/`; restores draw only from those backups. It never touches your git repository, makes no network requests, and accesses no credentials. For sessions you've left inactive for a long time, a global auto-cleanup (off by default) can remove their snapshot directory in whole, leaving the active session and the chat log untouched. Full security model: [SECURITY.md](SECURITY.md).
187
173
 
188
174
  ## Development
189
175
 
@@ -207,7 +193,7 @@ Contributing guide: [CONTRIBUTING.md](CONTRIBUTING.md).
207
193
  Releases go out through GitHub Actions Trusted Publishing (OIDC, no stored `NPM_TOKEN`): push a `v<version>` tag and CI publishes with Sigstore provenance.
208
194
 
209
195
  ```sh
210
- npm version patch && git push origin main --tags
196
+ npm version patch && git push origin <branch> --tags
211
197
  ```
212
198
 
213
199
  One-time npm-side setup and the full workflow details: [docs/release/release.md](docs/release/release.md).
package/README.md CHANGED
@@ -46,8 +46,8 @@ dsh plugin --profile web add dsh-rewind-plugin@<版本>
46
46
  ## 使用
47
47
 
48
48
  1. 在对话中找到要回退的那条用户消息,或输入 `/rewind`(或其别名 `/undo`)打开候选列表选择。
49
- 2. **选中它。** 小浮层提供两种模式(「回退对话和代码」仅在目标之后有可还原的变更时显示)。
50
- 3. 回退立即生效:对话回到目标消息当时的样子,被撤回消息的文本自动填入输入框——改完直接重发。
49
+ 2. **选中它。** 小浮层提供两种模式——「仅回退对话」或「回退对话和代码」。
50
+ 3. 回退立即生效:对话回到目标消息当时的样子,目标消息的文本自动回填输入框——改完直接重发。
51
51
 
52
52
  **键盘操作**:候选列表与模式浮层均支持 ↑↓ 移动、Enter 确认、Esc 取消/返回。
53
53
 
@@ -55,15 +55,15 @@ dsh plugin --profile web add dsh-rewind-plugin@<版本>
55
55
  <summary><b>边界说明</b></summary>
56
56
 
57
57
  - 回退可以反复进行——没有阶段或次数限制。
58
- - 回退本身**无法撤销**,但被撤回的内容仍保留在会话日志中,可手动编辑日志恢复。
58
+ - 回退本身**无法撤销**,但被撤回的内容仍保留在会话日志中。
59
59
  - **插话也能回退**——模型尚未读取的 `steering` 插话消息,同样可作为回退目标。
60
- - **回退会打断当前正在运行的回合**——确保回退安全执行。
60
+ - **回退会打断当前正在运行的回合**——确保回退的安全执行。
61
61
 
62
62
  </details>
63
63
 
64
64
  ## 存储管理
65
65
 
66
- 快照(写前备份)存储于 `<dsh home>/rewind-snapshots/`(未设 `$DSH_HOME` 时即 `~/.dsh/rewind-snapshots/`)。插件对**同一会话**的快照做内容去重(内容未变则存为链接)并保留最近 100 组锚点;**手动删除该目录**仅清除文件备份(对话回退不受影响),插件会自动重建。
66
+ 快照(写前备份)存储于 `<dsh home>/rewind-snapshots/`(默认 `~/.dsh/rewind-snapshots/`)。插件对**同一会话**的快照做内容去重并保留最近 100 组锚点;**手动删除该目录**仅清除文件备份(对话回退不受影响),插件会自动重建。
67
67
 
68
68
  另提供**全局自动清理**(默认关闭):把长期不活跃的会话快照整目录移除,不影响活动会话与对话日志。可在 `设置→插件→插件配置→快照清理` 面板查看与配置(自动清理开关、失活天数),也可用 `/snapshot-auto-cleanup` 命令查看、设置和运行。详见:[快照自动清理](docs/snapshot-auto-cleanup.zh.md)。
69
69
 
@@ -89,57 +89,44 @@ rm <dsh home>/snapshot-cleanup-last-sweep.json
89
89
  | 维度 | 常见做法 | 本插件 |
90
90
  | --- | --- | --- |
91
91
  | 对话回退 | Fork 分支新建对话 | **就地回退**——不新建会话、不切窗口,便捷回退 |
92
- | 文件还原 | 无还原功能 / git 管理或完整快照 | **写前轻量备份**——写文件前自动存原内容,一键还原(对齐 Claude Code) |
92
+ | 文件还原 | 无还原功能 / git 管理或完整快照 | **写前轻量备份**——写文件前自动存原内容,一键还原 |
93
93
  | 依赖 | 常依赖 Git 仓库或完整快照引擎 | **无依赖**——不依赖 git,普通目录即可用 |
94
- | 存储开销 | 整树快照占空间大 | **轻量**——只存被写工具改动过的文件,落盘持久化 |
94
+ | 存储开销 | 整树快照占空间大 | **轻量**——不变不存,且只追踪写类工具改动过的文件 |
95
95
 
96
96
  ## 原理
97
97
 
98
- 整套设计只有两条主线,核心哲学朴素却克制:**对话部分「只遮蔽、不删除」,文件部分「改前先备份,还原时对照真实磁盘」**。机制与 Claude Code checkpointing 同源——Claude Code 的文件历史也是逐文件记录 + 每条消息重扫已跟踪文件,并非整树快照;本插件把同一套语义落在 dsh 上,并做得更轻、更稳。
98
+ 整套设计只有两条主线,核心哲学朴素却克制:**对话部分「只遮蔽、不删除」**,使用 DSH 原生的「隐藏 + 替换」机制;**文件部分「局部追踪,写前轻量备份」**,参考 Claude Code 的检查点语义。
99
99
 
100
100
  ### 1. 对话回退:一次「遮蔽」,而不是「删除」
101
101
 
102
- `append-only` 是铁律:会话日志只追加、从不改写——这是可审计与信息安全的地基。回退从不动历史,它只做一步:往日志末尾追加一条**内容为空的标记消息**,把目标消息之后的全部内容「遮蔽 + 替换」掉,让模型和界面都只看得到目标之前的部分。
102
+ `append-only` 是铁律:会话日志只追加、从不改写——这是可审计与信息安全的地基。回退从不动历史,它只做一步:往日志末尾追加一条 **“空消息”标记**,把目标消息之后的全部内容「遮蔽 + 替换」掉,让模型和界面都只看得到目标之前的部分。
103
103
 
104
- - **标记是规范的**——插件复刻 `/compact` 标准的「隐藏 + 替换」:`/compact` 把一段历史压缩成摘要,`/rewind` 则换成一条空标记消息。由于其规范性,harness 的日志重放、`/compact` 压缩、续接检查都能正确识别它,绝不会把它误认为真实对话;
105
- - **替换内容无感**——模型对空标记忽略、无感(实测验证)。配合插件对界面显示的处理,模型和你看到的对话就是目标消息当时的样子,真正的「就地」;
106
- - 因为是「遮蔽」而非「删除」,**被撤回的每一条事件都完整留在日志里**,可审计、可追溯、可查看,原则上也随时能手动恢复。
104
+ - **同一份日志**——只在当前会话的日志做简单追加,不新建会话、不新建分支,因此不会留下残留和副本;
105
+ - **标记是规范的**——采用与官方 `/compact` 相同的「隐藏 + 替换」:`/compact` 把一段历史压缩成摘要,`/rewind` 则换成一条“空消息”标记。由于其规范性,DSH 的日志重放、压缩、续接检查都能正确识别它,绝不会把它误认为真实对话;
106
+ - **替换内容无感**——模型对标记忽略、无感(实测验证)。配合插件对界面显示的处理,模型和你看到的对话就是目标消息当时的样子;
107
+ - **记录完整保留**——因为是「遮蔽」而非「删除」,被撤回的内容完整留在日志里,可审计、可追溯,原则上也能手动恢复。
107
108
 
108
- > **设计点睛**:整个对话回退就是**一条**追加。它确定、可审计,且因为日志从未被破坏,回溯是「干净的」——用最小的动作,实现最完整的语义。那些与 harness 内部的兼容细节(对 `/compact` 的复刻、空消息的遮蔽)正是插件的专业所在,每一条都由专门的探针测试固化。
109
+ > **设计点睛**:整个对话回退就是**一条**追加。它确定、可审计,且因为日志从未被破坏,回溯是「干净的」——用最小的动作,实现最完整的语义。那些与 DSH 内部的兼容细节(对 `/compact` 的复刻、空消息的遮蔽)正是插件的专业所在。
109
110
 
110
- ### 2. 文件还原:轻量检查点,「改前备份」
111
+ ### 2. 文件还原:轻量检查点,「写前备份」
111
112
 
112
113
  文件部分对齐 Claude Code 的检查点语义——**局部追踪、写前备份 + 每条消息重扫已跟踪文件**,而不是整树快照。这项取舍既省空间,又更完整:
113
114
 
114
115
  - **写前备份**:只追踪写类工具(`write`、`edit`),写前**备份原内容**,并**记录、追踪**被处理的文件——从不备份整个工作区,因此轻量。
115
116
  - **外部变更也追**:每条用户消息边界,插件重新检查所有已跟踪文件——命令执行、手动修改等外部变更同样被记录,回退时一并还原。这让「轻量」却不「残缺」。
116
- - **不变不存、同内容存链接**:记录只在有变化时发生——消息边界重扫时无变更的不备份(不留记录);写前备份时若与前一条记录一致,只存**指向它的链接**(`ref`)而非复制内容。重复写入几乎不占空间,链接也先落地、绝不悬空。
117
- - **还原时对照真实磁盘**:先取每条路径的**最早**记录,再实时读取文件当前内容与之比对——**只操作真正不一致的文件**:改过的写回最早期内容、目标之后新建的删除、已经一致的跳过。重复回退因此**零副作用、幂等**,不会出现「幽灵影响」。
118
- - **安全边界**:符号/硬链接跳过,避免透过一个还原误伤另一个名字;路径经安全化处理,**绝不越出备份根目录**;单个文件失败绝不中止整轮还原。
117
+ - **不变不存**:记录只在有变化时发生——消息边界重扫时无变更的**不备份**(不留记录);写前备份时若与前一条记录一致,只存指向它的链接(`ref`)而非复制内容。
118
+ - **还原准确**:以备份为唯一标准,对照真实磁盘,只动真正不一致的文件——被改过的还原、被新建的删除、被删除的恢复;备份逐字节存储。还原结果与备份一致,无“幽灵影响”。
119
+ - **安全与完整性**:路径经安全化处理,绝不越出备份根目录;符号/硬链接跳过,避免透过一次还原误伤同名的另一份文件;单个文件失败绝不中止整轮还原;备份与还原日志均原子写落盘(跨重启仍在),断电或崩溃后的半还原可续做或回滚。
119
120
 
120
121
  > **设计点睛**:这套检查点的「轻」,来自**只记录被工具动过、且确实变化的文件**——写前备份保证可还原,不变不存与存链接压掉重复;还原时再对照真实磁盘,只动不一致的文件。
121
122
 
122
- ### 设计亮点一览
123
-
124
- | 设计 | 为什么值得 |
125
- | --- | --- |
126
- | 一次追加即一次回退 | 极小动作、极大语义,且日志从不被破坏 |
127
- | 只遮蔽、不删除 | 历史永远可审计,原则上可恢复 |
128
- | 改前备份 + 按轮分组 + 落盘 | 省空间、跨重启、对齐 Claude Code |
129
- | 同内容存为链接(去重) | 上百次重复写入几乎不占空间;淘汰组前先落地链接,绝不悬空 |
130
- | 会话级自动清理 | 只移除长期不活跃会话的快照,活动会话与对话日志永不触及 |
131
- | 对照真实磁盘再还原 | 幂等、零副作用、不误伤 |
132
- | 空消息遮蔽 + 对 `/compact` 的复刻 | 与宿主深度兼容,且被探针测试固化 |
133
- | 崩溃安全(原子写 + 还原日志) | 断电/崩溃后仍可续做或回滚 |
134
- | 纯函数规划 + 注入探针的存储 | 无需宿主即可单测,测试驱动 |
135
-
136
123
  ## 明确不做的事
137
124
 
138
125
  本插件刻意保持轻量、聚焦"对话回退"这一件事,以下场景**不属于它的职责**:
139
126
 
140
- - **整树 / Git 级快照**——只跟踪写类工具编辑 + 已跟踪文件的外部改动,从未被工具碰过的文件不还原。需要 Git 工作树级的完整快照回退时,请交给专门的快照工具(或你的 git)。
141
- - **子代理的编辑**——不追踪(同 Claude Code):子代理运行在自己的会话里,其备份无法由父会话的回退还原,只会在磁盘上残留。
142
- - **fork / 分支回退**——harness 已内置「在新对话中分支」,不重复造轮子。
127
+ - **整树 / Git 级快照**——只跟踪写类工具编辑 + 已跟踪文件的外部改动,从未被工具碰过的文件不还原。需要工作树级的完整快照回退时,请交给更专业的快照工具(git)。
128
+ - **子代理的编辑**——不追踪,子代理会话内也不提供回退(同 Claude Code):子代理运行在自己的会话里,其备份无法由父会话的回退还原,因此也不会为子会话保留备份。
129
+ - **fork / 分支回退**——DSH 已内置「在新对话中分支」,不重复造轮子。
143
130
 
144
131
  ## 兼容性
145
132
 
@@ -168,7 +155,7 @@ rm <dsh home>/snapshot-cleanup-last-sweep.json
168
155
 
169
156
  ## 安全
170
157
 
171
- 本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。工作区文件仅在「回退对话和代码」时被改写,备份存储于 `~/.dsh/rewind-snapshots/`;还原以备份为唯一来源。不触碰你的 git 仓库,无网络请求,不访问任何凭据。对**长期不活跃**的会话,另有默认关闭的全局自动清理可整目录移除其快照,不影响活动会话与对话日志。完整安全模型:[SECURITY.md](SECURITY.md)。
158
+ 本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。工作区文件仅在「回退对话和代码」时被改写,备份存储于 `<dsh home>/rewind-snapshots/`;还原以备份为唯一来源。不触碰你的 git 仓库,无网络请求,不访问任何凭据。对**长期不活跃**的会话,另有默认关闭的全局自动清理可整目录移除其快照,不影响活动会话与对话日志。完整安全模型:[SECURITY.md](SECURITY.md)。
172
159
 
173
160
  ## 开发
174
161
 
@@ -192,7 +179,7 @@ node scripts/verify-host.mjs # 端到端验证构建产物
192
179
  通过 GitHub Actions Trusted Publishing(OIDC,无存储 `NPM_TOKEN`)发布:推送 `v<版本>` tag,CI 即带 Sigstore provenance 发布。
193
180
 
194
181
  ```sh
195
- npm version patch && git push origin main --tags
182
+ npm version patch && git push origin <branch> --tags
196
183
  ```
197
184
 
198
185
  一次性 npm 侧配置与完整流程:见 [docs/release/release.zh.md](docs/release/release.zh.md)。
package/SECURITY.md CHANGED
@@ -12,9 +12,8 @@ The plugin runs in the DSH host process and therefore holds the host user's
12
12
  filesystem authority — it reads and writes files with plain `node:fs`. The
13
13
  following are treated as **untrusted inputs**:
14
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).
15
+ - **Model arguments** — the `file_path` field of `write` / `edit` tool calls
16
+ (it names the paths the checkpoint store records).
18
17
  - **Session log contents** — events are parsed structurally; a hostile or
19
18
  malformed id must never escape the store root.
20
19
  - **Current worktree state** — restore planning reconciles against the live
@@ -57,7 +56,13 @@ to exactly the apply-start state, and per-file failures are reported rather
57
56
  than hidden.
58
57
 
59
58
  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.
59
+ backup, or a cancelled invocation aborts the rewind with an error. A store
60
+ written by a **newer** build is one of those gates: it is refused as a whole —
61
+ no partial restore and no new entry written into it, and the automatic
62
+ format-change clear below is skipped — while the conversation rewind itself
63
+ still works. An explicit clear (`run --current --apply`) or the age-based sweep,
64
+ being a user-directed deletion of whole session directories, is deliberately not
65
+ version-gated.
61
66
 
62
67
  **Automatic store deletion**: two things delete session snapshots. The opt-in
63
68
  `snapshot-auto-cleanup` sweep (default off) removes the whole directories of
@@ -68,7 +73,9 @@ positions that no longer line up. Both stay confined to the store root, use
68
73
  `lstat` (so they never follow a symlink out of the root), the sweep never targets
69
74
  the active session, and neither touches the conversation log. When auto-cleanup
70
75
  is disabled (the default), only the format-change clear runs, and only for an
71
- affected session.
76
+ affected session. The store-format guard runs before that clear, so a session
77
+ whose store is **newer** than this build understands is not cleared by it (see
78
+ `docs/format.md`).
72
79
 
73
80
  ## Conversation integrity
74
81
 
@@ -91,29 +98,49 @@ transcript.
91
98
  `[a-zA-Z0-9._-]` (`safeSessionId` / `safeFileId`) before joining the store
92
99
  root; `.` and `..` bare values are replaced — hostile ids cannot traverse
93
100
  out of the root.
94
- - **Never written through links**: symlinked and hard-linked paths
95
- (`lstat().nlink > 1`) are skipped and reported, never restored a symlink
96
- would redirect the write outside the checkpoint, and a hard link would
97
- clobber every other name of the same inode (e.g. pnpm-installed files).
101
+ - **Never written through a link or a moved directory**: two checks guard a
102
+ tracked path before any write or unlink. (1) A path whose last component is a
103
+ symlink or a hard link (`lstat().nlink > 1`) is skipped and reported, never
104
+ restored a symlink would redirect the write outside the checkpoint, and a
105
+ hard link would clobber every other name of the same inode (e.g.
106
+ pnpm-installed files). (2) Every record this build writes — entry, link and
107
+ journal action — carries the `realpath` of the file's directory at commit time
108
+ (the location pin; best-effort, and absent on released-v1 data or a commit
109
+ whose parent could not be resolved), and a restore re-checks it before
110
+ touching the path, including a post-restart continue or rollback, which reads
111
+ the pin from the journal: a directory that no longer resolves there (a
112
+ repointed or moved ancestor) is refused and reported instead of writing
113
+ outside the recorded location, while data with no pin falls back to check (1)
114
+ alone. A parent chain that
115
+ is gone is still recreated (files whose directory was deleted are restorable),
116
+ but only while its nearest surviving ancestor resolves inside the pin. A
117
+ stable symlinked ancestor resolves identically on both sides, so a symlinked
118
+ workspace or temp root is never a false skip. This matches Claude Code's
119
+ checkpoint behavior since v2.1.216.
98
120
  - **Restores name only recorded paths**: the store contains resolved display
99
121
  paths of the session's own write-class tool calls (plus boundary re-checks
100
122
  over that same tracked set) — a restore can never write an arbitrary path.
101
123
  - **Path resolution rule**: relative paths resolve against the session
102
124
  workspace cwd, mirroring the fs tools' own rule (`src/session-cwd.ts`).
103
125
  - **Bounded backups**: `prune` keeps the newest 100 anchor groups per session
104
- (`MAX_ANCHOR_GROUPS`), so backup accumulation cannot grow the store without
105
- bound (the exact cap is pinned in `docs/format.md`). Across sessions, the
106
- opt-in `pruneStale` sweep removes whole **long-inactive** session directories
107
- (measured by the newest member being idle past `maxAgeDays`); it uses `lstat`
108
- (no symlink following), skips dot-prefixed temp files, and never targets the
109
- active session (`keepActiveId`).
126
+ (`MAX_ANCHOR_GROUPS`), except groups a non-terminal restore journal still
127
+ references (pinned so the op can be finished), so backup accumulation stays
128
+ bounded by the cap plus the number of unresolved journals (the exact cap is
129
+ pinned in `docs/format.md`). Across sessions, the opt-in `pruneStale` sweep
130
+ removes whole **long-inactive** session directories (measured by the newest
131
+ member being idle past `maxAgeDays`); it uses `lstat` (no symlink following),
132
+ skips dot-prefixed temp files, and never targets the active session
133
+ (`keepActiveId`).
110
134
 
111
135
  ## Crash safety
112
136
 
113
- - **Atomic commits**: every JSON write (checkpoint entries, restore journals)
114
- goes to a sibling temp file and is renamed over the target. A host crash
115
- mid-write can leave only an inert `<target>.tmp` never a readable
116
- half-written file and readers never pick up temp files.
137
+ - **Ordered commits**: a checkpoint commit places the before-sidecar (a raw
138
+ byte copy) before publishing the entry JSON, so a crash can leave an
139
+ unreferenced sidecar but never an entry whose bytes are missing. Every JSON
140
+ write (checkpoint entries, restore journals) goes to a sibling temp file and
141
+ is renamed over the target: a host crash mid-write can leave only an inert
142
+ `<target>.tmp` — never a readable half-written file — and readers never pick
143
+ up temp files.
117
144
  - **Journaled restores**: before mutating anything, the restore captures each
118
145
  planned path's pre-restore ("rescue") state and persists an intent journal,
119
146
  then marks each action done as it is applied. A crash at any point leaves
@@ -126,7 +153,9 @@ transcript.
126
153
  - **Fail-loud vs fail-soft**: a corrupt **journal** is reported
127
154
  `recovery-required` — never silently dropped (dropping it would erase the
128
155
  interrupted restore's recovery record). Corrupt **checkpoint entries** are
129
- silently ignored (they only lose one backup, not the recovery path).
156
+ silently ignored (they only lose one backup, not the recovery path), and a
157
+ record whose sidecar is missing or truncated fails only that file — never a
158
+ delete of the live file.
130
159
  - **Journal IO never fails the restore**: if the journal cannot be written the
131
160
  restore proceeds with pre-journal semantics (crash safety degrades,
132
161
  behavior does not).
@@ -136,9 +165,11 @@ transcript.
136
165
  - This plugin does **not** sandbox other processes or stop them from changing
137
166
  files concurrently.
138
167
  - It does **not** provide confidentiality or tamper resistance against the
139
- same operating-system user: state files are created with the process
140
- default permissions (no special modes are set — a standard umask applies),
141
- and the host user remains trusted.
168
+ same operating-system user: **store** files are created with the process
169
+ default permissions (a standard umask applies), and the host user remains
170
+ trusted. Restored **workspace** files do carry their recorded permission
171
+ bits: a content restore reapplies them (`chmod`, best-effort), while a mode
172
+ difference on its own never triggers a restore.
142
173
  - It does **not** exclude paths from the store, and it never oversteps: the
143
174
  plugin only adds backup/restore on top of permissions DSH already holds. Two
144
175
  distinct cases follow:
@@ -159,7 +190,8 @@ transcript.
159
190
  committed a backup.
160
191
  - **Subagent session edits are not tracked** (Claude Code alignment): a
161
192
  subagent runs its own session, so the files it changes are not backed up and
162
- cannot be restored by a rewind of the parent session.
193
+ cannot be restored by a rewind of the parent session; the plugin keeps no
194
+ snapshot for a subagent session and offers no rewind inside one.
163
195
 
164
196
  ## Reporting
165
197
 
@@ -81,7 +81,8 @@ Key invariants:
81
81
  on it, so the marker is appended while idle, outside any turn, as one event.
82
82
  - **Restore is reconciled against the live disk** (`planRestore`), so repeated
83
83
  rewinds are idempotent and a rewind whose target state already matches is a
84
- no-op.
84
+ no-op. A path whose directory no longer resolves to its commit-time location
85
+ is skipped and reported, never written through (see `SECURITY.md`).
85
86
 
86
87
  ### Marker format history
87
88
 
@@ -103,22 +104,32 @@ the `0.9.x` line) and is **removed in the `0.10.x` line**.
103
104
  ## Checkpoint pipeline (Claude Code before-backup model)
104
105
 
105
106
  ```
106
- tools/execute captureBefore: for write / edit
107
- (mutating commands only), read the file's BEFORE state;
108
- subagent edits are NOT tracked (Claude Code alignment).
107
+ tools/execute captureBefore: for write / edit, stage a raw byte copy of
108
+ the file's BEFORE state into the store's .pending/
109
+ (node:fs copyFile, never through a string);
110
+ subagent sessions are NOT tracked and get no rewind
111
+ surface at all (Claude Code alignment — the Harness
112
+ refuses generic Session RPCs for a subagent-owned
113
+ identity, so /rewind cannot execute there).
109
114
  tools/post-execute commitEntry: anchor = latest user/message seq; skip
110
- failed calls; write the before-backup to the store.
115
+ failed calls; publish the staged bytes as the entry's
116
+ sidecar and write the metadata beside them — including
117
+ where the directory resolved (`realpath`), the location
118
+ pin a restore re-checks.
111
119
  session/event user/message boundary: reconcileTracked re-reads every
112
120
  (user/message) tracked file and records a new before-backup for any
113
121
  whose disk state changed since last seen — external
114
122
  edits/deletions enter the record this way.
115
123
  prune keeps the newest 100 anchor groups per session, storing
116
124
  identical before-content as in-place links that are
117
- materialized before their group is dropped, and recycles
118
- terminal restore journals.
125
+ materialized before their group is dropped, never dropping
126
+ a group a non-terminal restore journal still references,
127
+ and recycles terminal restore journals.
119
128
  pruneStale cross-session auto-cleanup (default off): whole
120
129
  long-inactive session dirs past the cutoff are removed;
121
- the active session is never targeted.
130
+ the active session is never targeted (a subagent's tool
131
+ result never triggers the sweep — it owns no dir and would
132
+ claim that exemption).
122
133
  ```
123
134
 
124
135
  ## Compatibility strategy
@@ -45,7 +45,7 @@ version's shape only (no `Session.events` / `[data-time-hover-root]` /
45
45
  | I3 step/turn structure | Client turn-tail ordering, unique `step/start` (real turns), no ghost turns; the `user/message` rewind marker adds no step frame | `compat-invariants` I3, `helpers.assertTurnTailOrdering` |
46
46
  | I4 fold-service safety | stats / title / goal / projection fold a marker-bearing log without throwing, with predictable values | `compat-invariants` I4 |
47
47
  | I5 compact interop | A tool-call orphaned by a cancelled turn is pair-balanced once shadowed by a rewind; a rewind across a compaction checkpoint is explicitly refused; a rewind-then-compact transaction stays legal | `compat-interop` I5, `verify-host` 12/14 |
48
- | I6 tool pipeline | before-snapshot capture/commit/restore is correct (existing `snapshot.test.ts` + `verify-host` 4–8); cancellation timing never hangs | `verify-host` 4–8, 15 |
48
+ | I6 tool pipeline | before-snapshot capture/commit/restore is correct (existing `snapshot.test.ts` + `verify-host` 4–8) for the tracked tools `write` / `edit` — `str_replace_editor` is an optional DSH package that stopped being a default tool in DSH 0.1.3, so it is not tracked; cancellation timing never hangs | `verify-host` 4–8, 15 |
49
49
  | I7 client ordering | A log carrying tool turns and rewind markers (a single `user/message` replace) satisfies the client builder ordering | `compat-interop` I7 |
50
50
  | I8 runtime safety | `rewind`/`compact` combinations never leave a dangling step/turn frame | `verify-host` 15 |
51
51
 
@@ -32,7 +32,8 @@ console filter:
32
32
  | Scope | What an anomaly here means |
33
33
  | --- | --- |
34
34
  | `boot` | Startup identity — `loaded v<version> (build <hash>)`, gated by the verbose switch (see below). Confirms the running bundle matches a fix. |
35
- | `refill` | The composer refill after a rewind (command/wait/refill throws, an outcome that never settles) |
35
+ | `refill` | The composer refill after a rewind (command/wait/refill throws, a rejected or unmatched command, an outcome that never settles) |
36
+ | `preview` | The `/rewind preview` impact probe behind the mode popover (command rejected/unmatched/threw, an outcome that never settles) |
36
37
  | `portals` | Per-message button mount issues (e.g. no session binding) |
37
38
  | `settings` | The snapshot-cleanup settings card |
38
39
  | `hiding` | **Reserved** — no active alert at present. If a future row-hiding diagnostic is added, it belongs in this region. |
@@ -22,7 +22,8 @@
22
22
  | scope | 这里的异常含义 |
23
23
  | --- | --- |
24
24
  | `boot` | 启动身份行——`loaded v<version> (build <hash>)`,由详细输出开关控制(见下)。用于确认运行中的 bundle 是否匹配某个修复。 |
25
- | `refill` | 回退后的输入框回填(命令/等待/回填抛出异常、等待的结果始终未落定) |
25
+ | `refill` | 回退后的输入框回填(命令/等待/回填抛出异常、命令被拒绝或未匹配、等待的结果始终未落定) |
26
+ | `preview` | 模式气泡背后的 `/rewind preview` 影响探测(命令被拒绝/未匹配/抛出异常、等待的结果始终未落定) |
26
27
  | `portals` | 每条消息按钮的挂载问题(如无会话绑定) |
27
28
  | `settings` | 快照清理设置卡片 |
28
29
  | `hiding` | **保留区**——当前无活跃告警。若未来新增行隐藏诊断,应归属此区域。 |
@@ -9,16 +9,16 @@
9
9
  ## One-line summary
10
10
 
11
11
  Rewind **always** restores files edited with the dedicated editing tools
12
- (`write` / `edit` / `str_replace_editor`) because the plugin snapshots them
13
- before the edit. Command-line (or hand) edits are covered **only when the file
14
- was previously registered** by an editing tool in the same conversation — and,
15
- in that case, the change itself is recorded, but rewinding to a boundary inside
16
- an unchanged interval is only as precise as the next recorded state (see
12
+ (`write` / `edit`) because the plugin snapshots them before the edit.
13
+ Command-line (or hand) edits are covered **only when the file was previously
14
+ registered** by an editing tool in the same conversation — and, in that case,
15
+ the change itself is recorded, but rewinding to a boundary inside an unchanged
16
+ interval is only as precise as the next recorded state (see
17
17
  [Rewind precision](#rewind-precision-for-a-registered-file-changed-outside-editing-tools)).
18
18
 
19
19
  ## How file changes are classified
20
20
 
21
- **Editing tools** (create/overwrite, edit lines, insert): each call tells the
21
+ **Editing tools** (create/overwrite, line edits): each call tells the
22
22
  plugin *which file* it will change, so the plugin snapshots the file's current
23
23
  content **before** the edit and can restore it on rewind. This path is
24
24
  deterministic — using them always yields a restorable rewind.
@@ -4,11 +4,11 @@
4
4
 
5
5
  ## 一句话概括
6
6
 
7
- 回退**始终能还原**用专用编辑工具(`write` / `edit` / `str_replace_editor`)编辑过的文件,因为插件会在编辑前对其快照。**命令行(或手动)编辑**仅当该文件在同一会话中**先前已被某个编辑工具登记**时才会被覆盖——此时变更本身会被记录,但回退到"未变区间中间的边界"时,只能精确到**下一处被记录的状态**(见[已登记文件被编辑工具之外的方式改动时的回退精度](#已登记文件被编辑工具之外的方式改动时的回退精度))。
7
+ 回退**始终能还原**用专用编辑工具(`write` / `edit`)编辑过的文件,因为插件会在编辑前对其快照。**命令行(或手动)编辑**仅当该文件在同一会话中**先前已被某个编辑工具登记**时才会被覆盖——此时变更本身会被记录,但回退到"未变区间中间的边界"时,只能精确到**下一处被记录的状态**(见[已登记文件被编辑工具之外的方式改动时的回退精度](#已登记文件被编辑工具之外的方式改动时的回退精度))。
8
8
 
9
9
  ## 文件变更的分类
10
10
 
11
- **编辑工具**(创建/覆盖、按行编辑、插入):每次调用都会告诉插件*将要改动哪个文件*,因此插件会在编辑前对文件的当前内容**快照**,回退时能还原。这条路径是**确定性**的——用了就一定可还原。
11
+ **编辑工具**(创建/覆盖、按行编辑):每次调用都会告诉插件*将要改动哪个文件*,因此插件会在编辑前对文件的当前内容**快照**,回退时能还原。这条路径是**确定性**的——用了就一定可还原。
12
12
 
13
13
  **命令行或手动编辑**:Windows 上的 PowerShell(`pwsh`)写入、Linux 上的 `sed -i`,或手动保存文件。它们**不告诉**插件动了哪个文件,所以**没有可预先快照的对象**。
14
14