dsh-rewind-plugin 0.12.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/README.en.md +27 -41
- package/README.md +21 -34
- package/SECURITY.md +2 -1
- package/docs/architecture.md +7 -2
- package/docs/compat/diagnostics.md +2 -1
- package/docs/compat/diagnostics.zh.md +2 -1
- package/lib/client.js +77 -51
- package/lib/index.js +11 -6
- package/lib/types/client/portals.d.ts +34 -0
- package/package.json +1 -1
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
|
|
51
|
-
3. The rewind takes effect immediately: the conversation returns to how it looked at the target message, and the
|
|
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
|
|
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
|
|
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/`
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
-
-
|
|
117
|
-
- The
|
|
118
|
-
-
|
|
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
|
|
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, "
|
|
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
|
|
129
|
-
- **
|
|
130
|
-
- **Safety
|
|
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;
|
|
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
|
|
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** —
|
|
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.
|
|
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
|
-
-
|
|
58
|
+
- 回退本身**无法撤销**,但被撤回的内容仍保留在会话日志中。
|
|
59
59
|
- **插话也能回退**——模型尚未读取的 `steering` 插话消息,同样可作为回退目标。
|
|
60
|
-
-
|
|
60
|
+
- **回退会打断当前正在运行的回合**——确保回退的安全执行。
|
|
61
61
|
|
|
62
62
|
</details>
|
|
63
63
|
|
|
64
64
|
## 存储管理
|
|
65
65
|
|
|
66
|
-
快照(写前备份)存储于 `<dsh home>/rewind-snapshots
|
|
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 管理或完整快照 |
|
|
92
|
+
| 文件还原 | 无还原功能 / git 管理或完整快照 | **写前轻量备份**——写文件前自动存原内容,一键还原 |
|
|
93
93
|
| 依赖 | 常依赖 Git 仓库或完整快照引擎 | **无依赖**——不依赖 git,普通目录即可用 |
|
|
94
|
-
| 存储开销 | 整树快照占空间大 |
|
|
94
|
+
| 存储开销 | 整树快照占空间大 | **轻量**——不变不存,且只追踪写类工具改动过的文件 |
|
|
95
95
|
|
|
96
96
|
## 原理
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
整套设计只有两条主线,核心哲学朴素却克制:**对话部分「只遮蔽、不删除」**,使用 DSH 原生的「隐藏 + 替换」机制;**文件部分「局部追踪,写前轻量备份」**,参考 Claude Code 的检查点语义。
|
|
99
99
|
|
|
100
100
|
### 1. 对话回退:一次「遮蔽」,而不是「删除」
|
|
101
101
|
|
|
102
|
-
`append-only`
|
|
102
|
+
`append-only` 是铁律:会话日志只追加、从不改写——这是可审计与信息安全的地基。回退从不动历史,它只做一步:往日志末尾追加一条 **“空消息”标记**,把目标消息之后的全部内容「遮蔽 + 替换」掉,让模型和界面都只看得到目标之前的部分。
|
|
103
103
|
|
|
104
|
-
-
|
|
105
|
-
-
|
|
106
|
-
-
|
|
104
|
+
- **同一份日志**——只在当前会话的日志做简单追加,不新建会话、不新建分支,因此不会留下残留和副本;
|
|
105
|
+
- **标记是规范的**——采用与官方 `/compact` 相同的「隐藏 + 替换」:`/compact` 把一段历史压缩成摘要,`/rewind` 则换成一条“空消息”标记。由于其规范性,DSH 的日志重放、压缩、续接检查都能正确识别它,绝不会把它误认为真实对话;
|
|
106
|
+
- **替换内容无感**——模型对标记忽略、无感(实测验证)。配合插件对界面显示的处理,模型和你看到的对话就是目标消息当时的样子;
|
|
107
|
+
- **记录完整保留**——因为是「遮蔽」而非「删除」,被撤回的内容完整留在日志里,可审计、可追溯,原则上也能手动恢复。
|
|
107
108
|
|
|
108
|
-
> **设计点睛**:整个对话回退就是**一条**追加。它确定、可审计,且因为日志从未被破坏,回溯是「干净的」——用最小的动作,实现最完整的语义。那些与
|
|
109
|
+
> **设计点睛**:整个对话回退就是**一条**追加。它确定、可审计,且因为日志从未被破坏,回溯是「干净的」——用最小的动作,实现最完整的语义。那些与 DSH 内部的兼容细节(对 `/compact` 的复刻、空消息的遮蔽)正是插件的专业所在。
|
|
109
110
|
|
|
110
|
-
### 2.
|
|
111
|
+
### 2. 文件还原:轻量检查点,「写前备份」
|
|
111
112
|
|
|
112
113
|
文件部分对齐 Claude Code 的检查点语义——**局部追踪、写前备份 + 每条消息重扫已跟踪文件**,而不是整树快照。这项取舍既省空间,又更完整:
|
|
113
114
|
|
|
114
115
|
- **写前备份**:只追踪写类工具(`write`、`edit`),写前**备份原内容**,并**记录、追踪**被处理的文件——从不备份整个工作区,因此轻量。
|
|
115
116
|
- **外部变更也追**:每条用户消息边界,插件重新检查所有已跟踪文件——命令执行、手动修改等外部变更同样被记录,回退时一并还原。这让「轻量」却不「残缺」。
|
|
116
|
-
-
|
|
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 级快照**——只跟踪写类工具编辑 +
|
|
141
|
-
-
|
|
142
|
-
- **fork / 分支回退**——
|
|
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
|
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
667
|
-
|
|
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
|
|
916
|
-
bothState = { state: "error", message: outcome
|
|
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: "
|
|
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
|
|
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 =
|
|
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
|
};
|
|
@@ -1345,8 +1370,8 @@ function createRewindBridge(deps) {
|
|
|
1345
1370
|
}
|
|
1346
1371
|
|
|
1347
1372
|
// src/client/build-info.ts
|
|
1348
|
-
var PLUGIN_VERSION = true ? "0.12.
|
|
1349
|
-
var BUILD_HASH = true ? "
|
|
1373
|
+
var PLUGIN_VERSION = true ? "0.12.1" : "dev";
|
|
1374
|
+
var BUILD_HASH = true ? "248f750a" : "dev";
|
|
1350
1375
|
|
|
1351
1376
|
// src/client/locales.ts
|
|
1352
1377
|
var zh = {
|
|
@@ -1705,6 +1730,7 @@ function apply(ctx) {
|
|
|
1705
1730
|
const commandUi = ctx.get("commandUi");
|
|
1706
1731
|
const hasCandidates = (sessionId) => {
|
|
1707
1732
|
const face = sessionId === void 0 ? void 0 : sessionOf(sessionId);
|
|
1733
|
+
if (face === void 0 || isRewindInertSession(face.getSnapshot())) return false;
|
|
1708
1734
|
const chat = chatOf(face);
|
|
1709
1735
|
return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
|
|
1710
1736
|
};
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
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",
|