dsh-rewind-plugin 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -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.
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.
59
+ - A rewind itself **cannot be undone**, but the withdrawn content stays in the session log.
60
+ - **Interruptions rewind too** — a `steering` message the model hasn't read yet is also a valid rewind target, and withdrawing it does not interrupt the current run.
61
+ - **Rewinding a read message 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
 
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
- - 回退本身**无法撤销**,但被撤回的内容仍保留在会话日志中,可手动编辑日志恢复。
59
- - **插话也能回退**——模型尚未读取的 `steering` 插话消息,同样可作为回退目标。
60
- - **回退会打断当前正在运行的回合**——确保回退安全执行。
58
+ - 回退本身**无法撤销**,但被撤回的内容仍保留在会话日志中。
59
+ - **插话也能回退**——模型尚未读取的 `steering` 插话消息,同样可作为回退目标,不会打断当前生成。
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
 
package/SECURITY.md CHANGED
@@ -190,7 +190,8 @@ transcript.
190
190
  committed a backup.
191
191
  - **Subagent session edits are not tracked** (Claude Code alignment): a
192
192
  subagent runs its own session, so the files it changes are not backed up and
193
- 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.
194
195
 
195
196
  ## Reporting
196
197
 
@@ -107,7 +107,10 @@ the `0.9.x` line) and is **removed in the `0.10.x` line**.
107
107
  tools/execute captureBefore: for write / edit, stage a raw byte copy of
108
108
  the file's BEFORE state into the store's .pending/
109
109
  (node:fs copyFile, never through a string);
110
- subagent edits are NOT tracked (Claude Code alignment).
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).
111
114
  tools/post-execute commitEntry: anchor = latest user/message seq; skip
112
115
  failed calls; publish the staged bytes as the entry's
113
116
  sidecar and write the metadata beside them — including
@@ -124,7 +127,9 @@ prune keeps the newest 100 anchor groups per session, storing
124
127
  and recycles terminal restore journals.
125
128
  pruneStale cross-session auto-cleanup (default off): whole
126
129
  long-inactive session dirs past the cutoff are removed;
127
- 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).
128
133
  ```
129
134
 
130
135
  ## Compatibility strategy
@@ -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` | **保留区**——当前无活跃告警。若未来新增行隐藏诊断,应归属此区域。 |
package/lib/client.js CHANGED
@@ -167,6 +167,48 @@ function rewindOptionsFromCandidates(candidates, t) {
167
167
  }));
168
168
  }
169
169
 
170
+ // src/client/log.ts
171
+ var NS = "dsh-rewind";
172
+ var DEBUG_KEY = "dsh-rewind.debug";
173
+ var ALWAYS_ON = /* @__PURE__ */ new Set(["error", "warn"]);
174
+ function switchValue() {
175
+ try {
176
+ return window.localStorage.getItem(DEBUG_KEY) ?? "";
177
+ } catch {
178
+ return "";
179
+ }
180
+ }
181
+ function matches(value, ns) {
182
+ for (const entry of value.split(",")) {
183
+ const part = entry.trim();
184
+ if (part === "") continue;
185
+ if (part === "*" || part === `${NS}*`) return true;
186
+ if (part.endsWith("*")) {
187
+ if (ns.startsWith(part.slice(0, -1))) return true;
188
+ } else if (ns === part) {
189
+ return true;
190
+ }
191
+ }
192
+ return false;
193
+ }
194
+ function tag(scope) {
195
+ return `[${NS}:${scope}]`;
196
+ }
197
+ function log(level, scope, message, data) {
198
+ if (ALWAYS_ON.has(level)) {
199
+ console[level](tag(scope), message, data);
200
+ return;
201
+ }
202
+ if (!matches(switchValue(), `${NS}:${scope}`)) return;
203
+ console.info(tag(scope), message, data);
204
+ }
205
+ var rewindLog = {
206
+ error: (scope, message, data) => log("error", scope, message, data),
207
+ warn: (scope, message, data) => log("warn", scope, message, data),
208
+ info: (scope, message, data) => log("info", scope, message, data),
209
+ debug: (scope, message, data) => log("debug", scope, message, data)
210
+ };
211
+
170
212
  // src/client/styles.ts
171
213
  var CLASS = {
172
214
  button: "dsh-rewind-btn",
@@ -663,8 +705,21 @@ function isPreviewFor(node, seq) {
663
705
  }
664
706
  async function previewImpact(session, chatOf, seq, watch) {
665
707
  const known = knownCommandSeqs(session, chatOf, (node) => isPreviewFor(node, seq));
666
- const result = await session.command(`/rewind preview @${seq} both`);
667
- if (!result.ok || result.value?.matched !== true) return null;
708
+ let result;
709
+ try {
710
+ result = await session.command(`/rewind preview @${seq} both`);
711
+ } catch (error) {
712
+ rewindLog.warn("preview", `preview command threw for @${seq}`, error);
713
+ return { kind: "error", text: error instanceof Error ? error.message : String(error) };
714
+ }
715
+ if (!result.ok) {
716
+ rewindLog.warn("preview", `preview command rejected for @${seq}`, result.error);
717
+ return { kind: "error", text: `${result.error.code}: ${result.error.message}` };
718
+ }
719
+ if (result.value?.matched !== true) {
720
+ rewindLog.warn("preview", `preview command was not matched for @${seq}`);
721
+ return { kind: "error", text: "the rewind command is not registered on this host" };
722
+ }
668
723
  return waitForCommand(session, chatOf, (node) => isPreviewFor(node, seq) && !known.has(node.seq), 8e3, watch);
669
724
  }
670
725
  function el(tag2, className, text) {
@@ -912,13 +967,13 @@ function openPopover(opts) {
912
967
  impactOutcome = outcome;
913
968
  if (outcome !== null && outcome.kind === "success") {
914
969
  bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
915
- } else if (outcome !== null && outcome.kind === "error") {
916
- bothState = { state: "error", message: outcome.text ?? "unknown error" };
970
+ } else {
971
+ bothState = { state: "error", message: outcome?.text ?? "preview command timed out" };
917
972
  }
918
973
  renderModes();
919
974
  shell.position();
920
975
  })().catch(() => {
921
- bothState = { state: "hasChanges" };
976
+ bothState = { state: "error", message: "unexpected error" };
922
977
  renderModes();
923
978
  shell.position();
924
979
  });
@@ -948,48 +1003,6 @@ function retractSpan(steering, targetId) {
948
1003
  return steering.slice(index).map((item) => item.id);
949
1004
  }
950
1005
 
951
- // src/client/log.ts
952
- var NS = "dsh-rewind";
953
- var DEBUG_KEY = "dsh-rewind.debug";
954
- var ALWAYS_ON = /* @__PURE__ */ new Set(["error", "warn"]);
955
- function switchValue() {
956
- try {
957
- return window.localStorage.getItem(DEBUG_KEY) ?? "";
958
- } catch {
959
- return "";
960
- }
961
- }
962
- function matches(value, ns) {
963
- for (const entry of value.split(",")) {
964
- const part = entry.trim();
965
- if (part === "") continue;
966
- if (part === "*" || part === `${NS}*`) return true;
967
- if (part.endsWith("*")) {
968
- if (ns.startsWith(part.slice(0, -1))) return true;
969
- } else if (ns === part) {
970
- return true;
971
- }
972
- }
973
- return false;
974
- }
975
- function tag(scope) {
976
- return `[${NS}:${scope}]`;
977
- }
978
- function log(level, scope, message, data) {
979
- if (ALWAYS_ON.has(level)) {
980
- console[level](tag(scope), message, data);
981
- return;
982
- }
983
- if (!matches(switchValue(), `${NS}:${scope}`)) return;
984
- console.info(tag(scope), message, data);
985
- }
986
- var rewindLog = {
987
- error: (scope, message, data) => log("error", scope, message, data),
988
- warn: (scope, message, data) => log("warn", scope, message, data),
989
- info: (scope, message, data) => log("info", scope, message, data),
990
- debug: (scope, message, data) => log("debug", scope, message, data)
991
- };
992
-
993
1006
  // src/client/portals.tsx
994
1007
  var import_jsx_runtime = require("react/jsx-runtime");
995
1008
  function fillComposerEditable(text) {
@@ -1041,7 +1054,12 @@ async function runRewindAndFill(session, seq, mode, currentSessionId, chatOf, wa
1041
1054
  rewindLog.warn("refill", `rewind command threw, skipping refill @${seq}`, error);
1042
1055
  return;
1043
1056
  }
1044
- if (!result.ok || result.value?.matched !== true) {
1057
+ if (!result.ok) {
1058
+ rewindLog.warn("refill", `rewind command rejected for @${seq}`, result.error);
1059
+ return;
1060
+ }
1061
+ if (result.value?.matched !== true) {
1062
+ rewindLog.warn("refill", `rewind command was not matched for @${seq}`);
1045
1063
  return;
1046
1064
  }
1047
1065
  let outcome;
@@ -1130,6 +1148,13 @@ function collectTargets(chat, hiddenSeqs) {
1130
1148
  }
1131
1149
  return targets;
1132
1150
  }
1151
+ function isRewindInertSession(snapshot) {
1152
+ return snapshot.subagent !== null && snapshot.subagent !== void 0;
1153
+ }
1154
+ function collectDurableTargets(snapshot, chat, hiddenSeqs) {
1155
+ if (chat === void 0 || isRewindInertSession(snapshot)) return [];
1156
+ return collectTargets(chat, hiddenSeqs);
1157
+ }
1133
1158
  function bubbleTextOf(row) {
1134
1159
  const clone = row.cloneNode(true);
1135
1160
  clone.lastElementChild?.remove();
@@ -1205,7 +1230,7 @@ function RewindPortals({ sessionId, sessionOf, chatOf, currentSessionId, watchCh
1205
1230
  hidden.current.delete(seat);
1206
1231
  }
1207
1232
  }
1208
- const durable = chat === void 0 ? [] : collectTargets(chat, hiddenSeqs);
1233
+ const durable = collectDurableTargets(snapshot, chat, hiddenSeqs);
1209
1234
  const next = [...durable, ...collectPendingTargets(snapshot)];
1210
1235
  setTargets((current) => sameTargets(current, next) ? current : next);
1211
1236
  };
@@ -1298,13 +1323,22 @@ function composerText() {
1298
1323
  return surface.textContent ?? "";
1299
1324
  }
1300
1325
  async function retractPending(session, itemId, text, setComposerText) {
1301
- await session.cancel();
1302
1326
  const queue = session.getSnapshot().queue;
1303
1327
  const steering = queue.filter((item) => item.placement === "steering");
1328
+ let removedTarget = false;
1304
1329
  for (const id of retractSpan(steering, itemId)) {
1305
- await session.updateQueue(id, { kind: "remove" });
1330
+ let accepted = false;
1331
+ try {
1332
+ const result = await session.updateQueue(id, { kind: "remove" });
1333
+ accepted = result.ok;
1334
+ if (!result.ok) rewindLog.info("retract", `pending retract lost the race for ${id}`, result.error);
1335
+ } catch (error) {
1336
+ rewindLog.warn("retract", `pending retract remove threw for ${id}`, error);
1337
+ }
1338
+ if (!accepted) break;
1339
+ if (id === itemId) removedTarget = true;
1306
1340
  }
1307
- if (text !== null && text !== "" && composerText().trim() === "") {
1341
+ if (removedTarget && text !== null && text !== "" && composerText().trim() === "") {
1308
1342
  setComposerText(session.sessionId, text);
1309
1343
  }
1310
1344
  }
@@ -1345,8 +1379,8 @@ function createRewindBridge(deps) {
1345
1379
  }
1346
1380
 
1347
1381
  // src/client/build-info.ts
1348
- var PLUGIN_VERSION = true ? "0.12.0" : "dev";
1349
- var BUILD_HASH = true ? "d770983e" : "dev";
1382
+ var PLUGIN_VERSION = true ? "0.12.2" : "dev";
1383
+ var BUILD_HASH = true ? "c9f4bb5f" : "dev";
1350
1384
 
1351
1385
  // src/client/locales.ts
1352
1386
  var zh = {
@@ -1358,7 +1392,7 @@ var zh = {
1358
1392
  "popover.noText": "\uFF08\u65E0\u6587\u672C\uFF09",
1359
1393
  "popover.retract.title": "\u56DE\u9000\u5230\u8FD9\u6761\u63D2\u8BDD\u6D88\u606F",
1360
1394
  "popover.retract.target": "\u63D2\u8BDD\u4E2D \xB7 {preview}",
1361
- "popover.retract.hint": "\u5C06\u505C\u6B62\u5F53\u524D\u751F\u6210\uFF0C\u5E76\u56DE\u9000\u5230\u8BE5\u6D88\u606F\u4E4B\u524D",
1395
+ "popover.retract.hint": "\u64A4\u56DE\u8FD9\u6761\u53CA\u4E4B\u540E\u7684\u63D2\u8BDD\u6D88\u606F\uFF0C\u4E0D\u6253\u65AD\u5F53\u524D\u751F\u6210",
1362
1396
  "popover.retract.confirm": "\u786E\u8BA4\u56DE\u9000",
1363
1397
  "popover.chat": "\u4EC5\u56DE\u9000\u5BF9\u8BDD",
1364
1398
  "popover.chat.hint": "\u53EA\u56DE\u9000\u6A21\u578B\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u52A8\u5DE5\u4F5C\u533A\u6587\u4EF6",
@@ -1401,7 +1435,7 @@ var en = {
1401
1435
  "popover.noText": "(no text)",
1402
1436
  "popover.retract.title": "Rewind to this pending message",
1403
1437
  "popover.retract.target": "Pending \xB7 {preview}",
1404
- "popover.retract.hint": "Stops the current run and rewinds to before this message",
1438
+ "popover.retract.hint": "Retracts this pending message and the ones after it, without interrupting the run",
1405
1439
  "popover.retract.confirm": "Confirm rewind",
1406
1440
  "popover.chat": "Rewind conversation only",
1407
1441
  "popover.chat.hint": "Cut the model context only; workspace files stay untouched",
@@ -1705,6 +1739,7 @@ function apply(ctx) {
1705
1739
  const commandUi = ctx.get("commandUi");
1706
1740
  const hasCandidates = (sessionId) => {
1707
1741
  const face = sessionId === void 0 ? void 0 : sessionOf(sessionId);
1742
+ if (face === void 0 || isRewindInertSession(face.getSnapshot())) return false;
1708
1743
  const chat = chatOf(face);
1709
1744
  return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
1710
1745
  };
package/lib/index.js CHANGED
@@ -2248,6 +2248,10 @@ function shouldRunAutoSweep(lastAtMs, nowMs) {
2248
2248
  var name = "dsh-rewind";
2249
2249
  var inject = ["commands", "tools"];
2250
2250
  var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit"]);
2251
+ function isSubagentSession(session) {
2252
+ const header = session.header;
2253
+ return header.origin === "subagent" || (header.delegationDepth ?? 0) > 0;
2254
+ }
2251
2255
  var activeLocale = "en";
2252
2256
  var cleanupStore;
2253
2257
  function t(key, params) {
@@ -2306,8 +2310,7 @@ function isNotFoundError(error) {
2306
2310
  async function captureBefore(fs, store, exec, pending) {
2307
2311
  if (!TRACKED_TOOLS.has(exec.name)) return;
2308
2312
  const session = exec.agent?.session;
2309
- const header = session?.header;
2310
- if (header !== void 0 && (header.origin === "subagent" || (header.delegationDepth ?? 0) > 0)) return;
2313
+ if (session !== void 0 && isSubagentSession(session)) return;
2311
2314
  const path = mutationPathOf(exec);
2312
2315
  if (path === void 0) return;
2313
2316
  const cwd = execSessionCwd(exec, path);
@@ -2751,7 +2754,7 @@ function apply(ctx, config) {
2751
2754
  }, "dsh-rewind command");
2752
2755
  ctx.on("agent/session-start", ({ agent }) => {
2753
2756
  const session = agent.session;
2754
- if (session.header.origin === "subagent" || (session.header.delegationDepth ?? 0) > 0) return;
2757
+ if (isSubagentSession(session)) return;
2755
2758
  void (async () => {
2756
2759
  try {
2757
2760
  store.setFormatVersion(session.header.version);
@@ -2772,8 +2775,7 @@ function apply(ctx, config) {
2772
2775
  }, { global: true });
2773
2776
  ctx.on("session/event", (session, event) => {
2774
2777
  if (event.type !== "user/message") return;
2775
- const header = session.header;
2776
- if (header.origin === "subagent" || (header.delegationDepth ?? 0) > 0) return;
2778
+ if (isSubagentSession(session)) return;
2777
2779
  void (async () => {
2778
2780
  try {
2779
2781
  const sessionId = session.id;
@@ -2803,7 +2805,10 @@ function apply(ctx, config) {
2803
2805
  });
2804
2806
  scope.on("tools/post-execute", async (exec, result, next) => {
2805
2807
  try {
2806
- void maybeRunAutoCleanup(ctx, store, exec.agent?.session?.id, dshHome);
2808
+ const session = exec.agent?.session;
2809
+ if (session !== void 0 && !isSubagentSession(session)) {
2810
+ void maybeRunAutoCleanup(ctx, store, session.id, dshHome);
2811
+ }
2807
2812
  await commitEntry(store, pending, anchorCache, trackedBySession, exec, result);
2808
2813
  } catch (error) {
2809
2814
  ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
@@ -163,6 +163,40 @@ export declare function actionsContainerOf(row: HTMLElement | undefined): HTMLEl
163
163
  * is otherwise only reachable through a full React portal render.
164
164
  */
165
165
  export declare function collectTargets(chat: HiddenChat, hiddenSeqs: ReadonlySet<number>): readonly PortalTarget[];
166
+ /**
167
+ * The session-kind slice the durable-target gate reads — the session
168
+ * snapshot's `subagent` cell, the Harness's own runtime signal for a
169
+ * direct-subagent (child) session. Typed structurally so the plugin never
170
+ * imports the session-controller snapshot contract.
171
+ */
172
+ export interface SessionKindLike {
173
+ /** Non-null exactly while this Session is addressed as a subagent child. */
174
+ readonly subagent: unknown;
175
+ }
176
+ /**
177
+ * Whether a session is rewind-inert: a direct-subagent (child) session.
178
+ *
179
+ * The Harness refuses every generic Session RPC for a subagent-owned identity
180
+ * (`session/agent-busy`, "use subagent delivery for this child session"), so
181
+ * `/rewind` can never execute there — the command's own admission is the
182
+ * refusal, before any handler runs. A wired-up ↶ button in such a session is
183
+ * therefore dead UI that closes its popover and does nothing
184
+ * (SiriLee/dsh-rewind#26). This mirrors the Harness's own slash-command
185
+ * directory (which returns no commands for an addressed child), the pending
186
+ * path's `collectPendingTargets` gate, and the Host's `isSubagentSession`
187
+ * skips: a child session gets no rewind surface and records no snapshot.
188
+ */
189
+ export declare function isRewindInertSession(snapshot: SessionKindLike): boolean;
190
+ /**
191
+ * Collect the durable (sent-message) rewind targets of one session: none at all
192
+ * for a rewind-inert subagent session, otherwise `collectTargets`' DOM→target
193
+ * pairing. Kept separate from `collectTargets` so the session-kind gate is a
194
+ * pure, directly testable decision.
195
+ * @param snapshot - the session snapshot carrying the `subagent` cell.
196
+ * @param chat - the session's chat snapshot, or undefined while unavailable.
197
+ * @param hiddenSeqs - anchor seqs withdrawn by previous rewinds.
198
+ */
199
+ export declare function collectDurableTargets(snapshot: SessionKindLike, chat: HiddenChat | undefined, hiddenSeqs: ReadonlySet<number>): readonly PortalTarget[];
166
200
  interface RewindPortalsProps extends RewindBridgeDeps {
167
201
  readonly sessionId: string;
168
202
  }
@@ -178,6 +212,30 @@ export declare function RewindPortals({ sessionId, sessionOf, chatOf, currentSes
178
212
  * Empty when the composer is absent. Exported as a test seam (the
179
213
  * empty-composer guard in `retractPending`). */
180
214
  export declare function composerText(): string;
215
+ /**
216
+ * Withdraw one pre-sent (pending steering) message and every steering message
217
+ * after it (the rollback point's "future"), WITHOUT interrupting the run.
218
+ *
219
+ * The message is still in the agent's next-step inbox, so the loop has not
220
+ * claimed it into any request: removing it cannot change what the model is
221
+ * generating. Stopping the run first (the durable-rewind rule) would throw
222
+ * away in-flight work — a long tool call, a partial answer — for an edit the
223
+ * model can never observe (SiriLee/dsh-rewind#27). Only the durable rewind
224
+ * needs a stop, because it cuts the model-visible surface.
225
+ *
226
+ * 1. Remove the target and its future (steering only; queued stays) through
227
+ * the session's own `updateQueue` channel, oldest first.
228
+ * 2. Put the target's text back in the composer ONLY when the target was
229
+ * actually removed and the composer is empty (Claude Code's auto-restore
230
+ * guard, so a draft the user is typing is never clobbered).
231
+ *
232
+ * The realistic failure is `queue-item-not-found`: a step boundary claimed
233
+ * the whole next-step batch a moment ago, so the message is now durable and
234
+ * the durable row's regular rewind button takes over with no gap. Nothing is
235
+ * removed then, and the composer is NOT refilled — refilling would invite a
236
+ * duplicate send of a message the model has already received.
237
+ */
238
+ export declare function retractPending(session: SessionFace, itemId: string, text: string | null, setComposerText: (sessionId: string, text: string) => boolean): Promise<void>;
181
239
  /**
182
240
  * Build the slot-entry component for the plugin apply(): a tiny bridge that
183
241
  * injects the apply-time capabilities (session resolution, locale, rewind
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "DSH 插件:真正便捷无感的同窗口内对话回退,从不新建分支;自带轻量工作区备份,可一并还原文件(完整 Claude Code /rewind 语义)。 · DSH plugin: genuinely effortless in-window conversation rewind — never forking a new session; ships a lightweight workspace backup that restores files together with the rewind (full Claude Code /rewind semantics).",
5
5
  "keywords": [
6
6
  "deepseek-harness",