dsh-rewind-plugin 0.20.0-alpha.3 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +14 -18
- package/README.md +15 -18
- package/SECURITY.md +133 -156
- package/lib/client.js +44 -24
- package/lib/index.js +47 -34
- package/lib/types/client/candidates.d.ts +2 -1
- package/lib/types/client/host-api.d.ts +27 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/popover.d.ts +3 -3
- package/lib/types/locales.d.ts +1 -1
- package/lib/types/snapshot.d.ts +1 -1
- package/package.json +59 -60
- package/lib/types/client/host-rpc.d.ts +0 -45
package/README.en.md
CHANGED
|
@@ -34,19 +34,17 @@ Every user message carries a **↶ rewind** button in its action row. Clicking i
|
|
|
34
34
|
|
|
35
35
|
## Install
|
|
36
36
|
|
|
37
|
-
Check your local DSH version, then find the matching plugin version in
|
|
38
|
-
[Releases](https://github.com/SiriLee/dsh-rewind/releases).
|
|
39
|
-
|
|
40
37
|
**Command line**:
|
|
41
38
|
|
|
42
39
|
```sh
|
|
43
|
-
dsh plugin --profile <name> add dsh-rewind-plugin
|
|
40
|
+
dsh plugin --profile <name> add dsh-rewind-plugin
|
|
44
41
|
```
|
|
45
42
|
|
|
46
|
-
**Graphical interface**: the **Plugins** page in the sidebar → **Add plugin** → enter `dsh-rewind-plugin
|
|
43
|
+
**Graphical interface**: the **Plugins** page in the sidebar → **Add plugin** → enter `dsh-rewind-plugin` → install → restart DSH.
|
|
44
|
+
|
|
45
|
+
<img src="assets/screenshots/add-plugin.png" width="600" alt="Add plugin dialog: enter dsh-rewind-plugin in the field"><br>
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
<sub>The screenshot is an example only — use the version that matches your DSH version.</sub>
|
|
47
|
+
The default install is `latest`, which targets the latest stable DSH release. If you are on an older or alpha DSH version, find the matching plugin version on the [Releases](https://github.com/SiriLee/dsh-rewind/releases) page and install that version explicitly.
|
|
50
48
|
|
|
51
49
|
> [!WARNING]
|
|
52
50
|
> The npm name `dsh-rewind` belongs to another author's package — install with `dsh-rewind-plugin`.
|
|
@@ -130,21 +128,21 @@ The whole design rests on two principles, simple but deliberate: **the conversat
|
|
|
130
128
|
|
|
131
129
|
### 2. File restore: lightweight checkpointing, "before-write backup"
|
|
132
130
|
|
|
133
|
-
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
|
|
131
|
+
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 while still covering external changes to tracked files:
|
|
134
132
|
|
|
135
133
|
- **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; **a single file that is too large is not backed up**.
|
|
136
134
|
- **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".
|
|
137
135
|
- **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.
|
|
138
|
-
- **Accurate restore**: backups are the sole standard, checked against the real disk — **only files that actually differ are touched**. Backups are stored byte for byte, so the restored result matches the backups exactly, with no "ghost impact".
|
|
136
|
+
- **Accurate restore**: backups are the sole standard, checked against the real disk — **only files that actually differ are touched**. Backups are stored byte for byte, so the restored result matches the backups exactly, with no "ghost impact". Coverage is bounded by design: [File-rewind tracking boundary](docs/compat/tracking-boundary.md).
|
|
139
137
|
- **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.
|
|
140
138
|
|
|
141
139
|
> **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.
|
|
142
140
|
|
|
143
|
-
##
|
|
141
|
+
## Scope and division of labour
|
|
144
142
|
|
|
145
|
-
This plugin deliberately stays lightweight and focused on one thing — "conversation rewind". The following are **out of its scope
|
|
143
|
+
This plugin deliberately stays lightweight and focused on one thing — "conversation rewind". The following are **out of its scope**, and each has a better owner (see [design boundaries](docs/boundaries.md)):
|
|
146
144
|
|
|
147
|
-
- **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
|
|
145
|
+
- **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. Workspace history — any file, any moment — belongs to Git; the agent already uses it, and the plugin does not rebuild that layer. For a worktree-level full snapshot rollback, use Git or the snapshot tool the project already uses.
|
|
148
146
|
- **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.
|
|
149
147
|
- **Fork / branch rewind** — DSH already provides this ("branch in new chat"); no need to reinvent the wheel.
|
|
150
148
|
|
|
@@ -158,12 +156,10 @@ This plugin deliberately stays lightweight and focused on one thing — "convers
|
|
|
158
156
|
> This project and DeepSeek Harness are both in developer preview. Pin exact
|
|
159
157
|
> versions in reproducible environments and review the behavior notes above.
|
|
160
158
|
|
|
161
|
-
## Known
|
|
159
|
+
## Known limitations
|
|
162
160
|
|
|
163
|
-
1. **
|
|
164
|
-
2. **
|
|
165
|
-
3. **Turn navigation keeps rewound turns** — the right-side turn navigation keeps an "unloaded" tick for each rewound turn, and hovering previews the withdrawn content. Only a display difference; no functional impact.
|
|
166
|
-
4. **A far rewind may show a blank transcript** — when the target predates the loaded history, the conversation area goes blank; click **Load earlier** to page back until the history appears.
|
|
161
|
+
1. **Turn navigation keeps rewound turns** — the right-side turn navigation keeps an "unloaded" tick for each rewound turn, and hovering previews the withdrawn content. Only a display difference; no functional impact.
|
|
162
|
+
2. **A far rewind may show a blank transcript** — when the target predates the loaded history, the conversation area goes blank; click **Load earlier** to page back until the history appears.
|
|
167
163
|
|
|
168
164
|
> [!NOTE]
|
|
169
165
|
> Browser diagnostics are available; see [Browser diagnostics](docs/compat/diagnostics.md).
|
|
@@ -185,7 +181,7 @@ node scripts/verify-host.mjs # end-to-end verification of the built artifact
|
|
|
185
181
|
|
|
186
182
|
`prepack` runs the full build, so `npm pack` / `npm publish` always ship a complete `lib/`. Install from npm or a packed tarball — a git checkout has no built `lib/`.
|
|
187
183
|
|
|
188
|
-
Maintainers: the
|
|
184
|
+
Maintainers: the source layout and architecture are in [docs/architecture.md](docs/architecture.md); the harness interface reference is in [docs/harness-reference.md](docs/harness-reference.md).
|
|
189
185
|
|
|
190
186
|
Contributing guide: [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
191
187
|
|
package/README.md
CHANGED
|
@@ -34,18 +34,17 @@ DeepSeek Harness 插件:**一键就地回退对话到任意更早的用户消
|
|
|
34
34
|
|
|
35
35
|
## 安装
|
|
36
36
|
|
|
37
|
-
确认本机 DSH 版本后,在 [Release](https://github.com/SiriLee/dsh-rewind/releases) 找到适配的插件版本。
|
|
38
|
-
|
|
39
37
|
**命令行**:
|
|
40
38
|
|
|
41
39
|
```sh
|
|
42
|
-
dsh plugin --profile <name> add dsh-rewind-plugin
|
|
40
|
+
dsh plugin --profile <name> add dsh-rewind-plugin
|
|
43
41
|
```
|
|
44
42
|
|
|
45
|
-
**图形界面**:侧栏「插件」页 →「添加插件」→ 填 `dsh-rewind-plugin
|
|
43
|
+
**图形界面**:侧栏「插件」页 →「添加插件」→ 填 `dsh-rewind-plugin` → 安装 → 重启 DSH。
|
|
44
|
+
|
|
45
|
+
<img src="assets/screenshots/add-plugin.png" width="600" alt="「添加插件」弹窗:在输入框填入 dsh-rewind-plugin"><br>
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
<sub>图片仅为示例,请以实际适配版本号为准。</sub>
|
|
47
|
+
默认安装 `latest`,它适配 DSH 最新稳定版本。若使用的是旧版或内测版 DSH,请在 [Releases](https://github.com/SiriLee/dsh-rewind/releases) 页面找到适配的插件版本,并指定版本安装。
|
|
49
48
|
|
|
50
49
|
> [!WARNING]
|
|
51
50
|
> npm 上的 `dsh-rewind` 属于其他作者,请用 `dsh-rewind-plugin` 安装。
|
|
@@ -116,23 +115,23 @@ rm <dsh home>/snapshot-cleanup-last-sweep.json
|
|
|
116
115
|
|
|
117
116
|
### 2. 文件还原:轻量检查点,「写前备份」
|
|
118
117
|
|
|
119
|
-
文件部分对齐 Claude Code 的检查点语义——**局部追踪、写前备份 +
|
|
118
|
+
文件部分对齐 Claude Code 的检查点语义——**局部追踪、写前备份 + 每条消息重扫已跟踪文件**,而不是整树快照。这项取舍省空间,也让已跟踪文件的外部改动同样可还原:
|
|
120
119
|
|
|
121
120
|
- **写前备份**:只追踪写类工具(`write`、`edit`),写前**备份原内容**,并**记录、追踪**被处理的文件——从不备份整个工作区,因此轻量;**单个文件过大时不备份**。
|
|
122
121
|
- **外部变更也追**:每条用户消息边界,插件重新检查所有已跟踪文件——命令执行、手动修改等外部变更同样被记录,回退时一并还原。这让「轻量」却不「残缺」。
|
|
123
122
|
- **不变不存**:记录只在有变化时发生——消息边界重扫时无变更的**不备份**(不留记录);写前备份时若与前一条记录一致,只存指向它的链接(`ref`)而非复制内容。
|
|
124
|
-
-
|
|
123
|
+
- **还原准确**:以备份为唯一标准,对照真实磁盘,只动真正不一致的文件;备份逐字节存储。还原结果与备份一致,无“幽灵影响”。覆盖范围按设计限定:[文件回退的追踪边界](docs/compat/tracking-boundary.zh.md)。
|
|
125
124
|
- **安全与完整性**:路径经安全化处理,绝不越出备份根目录;符号/硬链接跳过,避免透过一次还原误伤同名的另一份文件;单个文件失败绝不中止整轮还原;备份与还原日志均原子写落盘(跨重启仍在),断电或崩溃后的半还原可续做或回滚。
|
|
126
125
|
|
|
127
126
|
> **设计点睛**:这套检查点的「轻」,来自**只记录被工具动过、且确实变化的文件**——写前备份保证可还原,不变不存与存链接压掉重复;还原时再对照真实磁盘,只动不一致的文件。
|
|
128
127
|
|
|
129
|
-
##
|
|
128
|
+
## 范围与分工
|
|
130
129
|
|
|
131
|
-
|
|
130
|
+
本插件刻意保持轻量、聚焦「对话回退」这一件事,以下场景**不属于它的职责**,各自有更合适的归属(见[设计边界](docs/boundaries.zh.md)):
|
|
132
131
|
|
|
133
|
-
- **整树 / Git 级快照**——只跟踪写类工具编辑 +
|
|
132
|
+
- **整树 / Git 级快照**——只跟踪写类工具编辑 + 已跟踪文件的外部改动,从未被工具碰过的文件不还原。工作区历史(任意文件、任意时刻)归 Git;Agent 本来就在用 Git,插件不重复造这层。需要工作树级的完整快照回退时,请交给 Git 或项目自选的快照工具。
|
|
134
133
|
- **子代理的编辑**——不追踪,子代理会话内也不提供回退(同 Claude Code):子代理运行在自己的会话里,其备份无法由父会话的回退还原,因此也不会为子会话保留备份。
|
|
135
|
-
- **fork / 分支回退**——DSH
|
|
134
|
+
- **fork / 分支回退**——DSH 已内置「在新对话中分支」功能,不重复造轮子。
|
|
136
135
|
|
|
137
136
|
## 兼容性
|
|
138
137
|
|
|
@@ -144,12 +143,10 @@ rm <dsh home>/snapshot-cleanup-last-sweep.json
|
|
|
144
143
|
> 本项目与 DeepSeek Harness 均处于开发者预览阶段。可复现环境请 pin 精确版本,
|
|
145
144
|
> 并阅读上述行为说明。
|
|
146
145
|
|
|
147
|
-
##
|
|
146
|
+
## 已知限制
|
|
148
147
|
|
|
149
|
-
1.
|
|
150
|
-
2.
|
|
151
|
-
3. **轮次导航保留已回退轮次**——右侧轮次导航为已回退轮次保留「未加载」刻度,悬浮可预览已撤回内容。仅显示差异,无功能影响。
|
|
152
|
-
4. **较远回退可能显示空白**——回退目标早于已加载的历史时,对话区会显示空白;需点击「加载更早」往前翻,才能看到历史对话。
|
|
148
|
+
1. **轮次导航保留已回退轮次**——右侧轮次导航为已回退轮次保留「未加载」刻度,悬浮可预览已撤回内容。仅显示差异,无功能影响。
|
|
149
|
+
2. **较远回退可能显示空白**——回退目标早于已加载的历史时,对话区会显示空白;需点击「加载更早」往前翻,才能看到历史对话。
|
|
153
150
|
|
|
154
151
|
> [!NOTE]
|
|
155
152
|
> 本插件提供浏览器端诊断输出;详见 [浏览器诊断](docs/compat/diagnostics.zh.md)。
|
|
@@ -171,7 +168,7 @@ node scripts/verify-host.mjs # 端到端验证构建产物
|
|
|
171
168
|
|
|
172
169
|
`prepack` 在打包前执行完整构建,所以 `npm pack` / `npm publish` 总会产出完整的 `lib/`。请从 npm 或打好的 tarball 安装——git 检出的源码里没有构建好的 `lib/`。
|
|
173
170
|
|
|
174
|
-
|
|
171
|
+
维护者:源码布局与架构见 [docs/architecture.md](docs/architecture.md),harness 接口参考见 [docs/harness-reference.md](docs/harness-reference.md)。
|
|
175
172
|
|
|
176
173
|
贡献指南:[CONTRIBUTING.md](CONTRIBUTING.md)
|
|
177
174
|
|
package/SECURITY.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Security model
|
|
2
2
|
|
|
3
3
|
This document is the security model of **dsh-rewind** — how the plugin treats
|
|
4
|
-
untrusted input, what it is allowed to mutate,
|
|
5
|
-
It is derived from the implementation
|
|
6
|
-
this document and the code ever disagree, the code wins and this document is
|
|
7
|
-
|
|
4
|
+
untrusted input, what it is allowed to mutate, how it contains filesystem
|
|
5
|
+
writes, and how it survives crashes. It is derived from the implementation; if
|
|
6
|
+
this document and the code ever disagree, the code wins and this document is a
|
|
7
|
+
bug.
|
|
8
8
|
|
|
9
9
|
## Trusted boundary
|
|
10
10
|
|
|
@@ -17,190 +17,167 @@ following are treated as **untrusted inputs**:
|
|
|
17
17
|
- **Session log contents** — events are parsed structurally; a hostile or
|
|
18
18
|
malformed id must never escape the store root.
|
|
19
19
|
- **Current worktree state** — restore planning reconciles against the live
|
|
20
|
-
disk, which may have
|
|
20
|
+
disk, which anything may have changed.
|
|
21
21
|
- **Concurrent external modifications** — a restore never assumes the disk
|
|
22
22
|
still matches its records.
|
|
23
23
|
|
|
24
|
-
The DSH host (and its other plugins) is trusted; this plugin does not
|
|
25
|
-
|
|
24
|
+
The DSH host (and its other plugins) is trusted; this plugin does not re-verify
|
|
25
|
+
the host's own authority boundaries.
|
|
26
26
|
|
|
27
27
|
## Mutation gates
|
|
28
28
|
|
|
29
|
-
When
|
|
29
|
+
When snapshot backups are on, the plugin **automatically captures**
|
|
30
30
|
before-backups of tracked mutations, but **never automatically applies** one.
|
|
31
|
-
A workspace restore happens only through an explicit user
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
3. **Committed backups exist**: every planned action comes from a committed
|
|
31
|
+
A workspace restore happens only through an explicit user action (the
|
|
32
|
+
per-message ↶ button or `/rewind`), and only when all of the following hold:
|
|
33
|
+
|
|
34
|
+
1. **A validated target** — a `user/message` seq currently on the session
|
|
35
|
+
surface.
|
|
36
|
+
2. **A fresh plan** — derived from the current events and surface at execution
|
|
37
|
+
time, never cached across events.
|
|
38
|
+
3. **Committed backups exist** — every planned action comes from a committed
|
|
40
39
|
checkpoint entry anchored at or after the target.
|
|
41
|
-
4. **Live-disk reconciliation
|
|
42
|
-
|
|
40
|
+
4. **Live-disk reconciliation** — each entry is compared with the current disk
|
|
41
|
+
and only actions that would actually change it are planned; an
|
|
43
42
|
already-matching state is a no-op (restores are idempotent).
|
|
44
|
-
5. **Exclusive execution
|
|
45
|
-
rewinds; a running turn is
|
|
46
|
-
awaited before the surface is cut.
|
|
47
|
-
6. **Session binding
|
|
48
|
-
session
|
|
49
|
-
|
|
50
|
-
**Concurrency scope
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
than hidden.
|
|
43
|
+
5. **Exclusive execution** — a per-session in-flight guard rejects concurrent
|
|
44
|
+
rewinds; a running turn is cancelled (queued messages are kept) and
|
|
45
|
+
quiescence is awaited before the surface is cut.
|
|
46
|
+
6. **Session binding** — the restore reads and writes only the rewound
|
|
47
|
+
session's store, over paths that session's own tools touched.
|
|
48
|
+
|
|
49
|
+
**Concurrency scope:** the guard serializes concurrent *rewinds* of one session;
|
|
50
|
+
it does not stop other processes from changing the workspace. The plan is
|
|
51
|
+
reconciled against the live disk at plan time, with no re-validation before
|
|
52
|
+
apply, so a file changed in that window is overwritten by the restore. The
|
|
53
|
+
pre-restore state is captured at apply start, so a rollback still returns the
|
|
54
|
+
workspace to exactly that state, and per-file failures are reported rather than
|
|
55
|
+
hidden.
|
|
58
56
|
|
|
59
57
|
A failed gate fails closed: an invalid target, a missing store, an absent
|
|
60
58
|
backup, or a cancelled invocation aborts the rewind with an error. A store
|
|
61
|
-
written by a **newer** build is
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
the
|
|
76
|
-
is disabled (the default), only the format-change clear runs, and only for an
|
|
77
|
-
affected session. The store-format guard runs before that clear, so a session
|
|
78
|
-
whose store is **newer** than this build understands is not cleared by it (see
|
|
79
|
-
`docs/format.md`).
|
|
59
|
+
written by a **newer** build is refused as a whole — no partial restore, no new
|
|
60
|
+
entry, and no automatic format-change clear — while the conversation rewind
|
|
61
|
+
still works. An explicit clear or the age-based sweep, being user-directed
|
|
62
|
+
deletion of whole session directories, is deliberately not version-gated.
|
|
63
|
+
|
|
64
|
+
**Automatic store deletion.** Two things delete session snapshots: the opt-in
|
|
65
|
+
`snapshot-auto-cleanup` sweep (off by default) removes whole directories of
|
|
66
|
+
long-inactive sessions; separately, a session's snapshots are cleared when the
|
|
67
|
+
session format has changed (a DSH upgrade), because the backups are anchored to
|
|
68
|
+
message positions that no longer line up. Both stay confined to the store root,
|
|
69
|
+
never follow a symlink out of it, never touch the conversation log, and the
|
|
70
|
+
sweep never targets the active session. The format guard runs before the clear,
|
|
71
|
+
so a store newer than this build is not cleared by it. See
|
|
72
|
+
[snapshot auto-cleanup](docs/snapshot-auto-cleanup.md) and
|
|
73
|
+
[the store format](docs/format.md).
|
|
80
74
|
|
|
81
75
|
## Conversation integrity
|
|
82
76
|
|
|
83
77
|
The session log is **append-only** — the plugin never deletes or rewrites
|
|
84
|
-
recorded history. A rewind appends a single marker event
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
78
|
+
recorded history. A rewind appends a single marker event whose content is a
|
|
79
|
+
constant placeholder and whose surface operation replaces every surface node
|
|
80
|
+
after the target. The raw log (audit trail, search, `/export`) is untouched;
|
|
81
|
+
only the model-visible surface is cut, so the next request derives its context
|
|
82
|
+
from the target onward. The marker's constant content carries no untrusted text
|
|
83
|
+
into the model context, and the client hides the withdrawn span from the
|
|
84
|
+
rendered transcript. The marker shape is specified in
|
|
85
|
+
[architecture](docs/architecture.md).
|
|
91
86
|
|
|
92
87
|
## Filesystem containment
|
|
93
88
|
|
|
94
|
-
- **Store root**: `<dsh home>/rewind-snapshots/` by default (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
the
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
alone. A parent chain that
|
|
118
|
-
is gone is still recreated (files whose directory was deleted are restorable),
|
|
119
|
-
but only while its nearest surviving ancestor resolves inside the pin. A
|
|
120
|
-
stable symlinked ancestor resolves identically on both sides, so a symlinked
|
|
121
|
-
workspace or temp root is never a false skip. This matches Claude Code's
|
|
122
|
-
checkpoint behavior since v2.1.216.
|
|
89
|
+
- **Store root**: `<dsh home>/rewind-snapshots/` by default (`$DSH_HOME` feeds
|
|
90
|
+
the base, `~/.dsh` is the fallback); the `snapshotDir` config overrides the
|
|
91
|
+
whole path. The plugin reads no environment variable of its own. Deleting the
|
|
92
|
+
root only removes file backups; the store rebuilds.
|
|
93
|
+
- **Path sanitization**: session and call ids are scrubbed to a safe character
|
|
94
|
+
set before joining the store root; bare `.` and `..` are replaced — hostile
|
|
95
|
+
ids cannot traverse out of the root.
|
|
96
|
+
- **Links are refused**: planning skips a path whose last component is a
|
|
97
|
+
symlink or a hard link and reports it — a symlink would redirect the write
|
|
98
|
+
outside the checkpoint, and a hard link would clobber every other name of the
|
|
99
|
+
same inode. A link swapped in after planning is not re-checked.
|
|
100
|
+
- **Recorded locations are re-checked**: every record written by this build
|
|
101
|
+
carries the directory location resolved at commit time; a restore re-checks
|
|
102
|
+
it before touching the path, including after a restart. A directory that no
|
|
103
|
+
longer resolves there is refused and reported; a missing parent chain is
|
|
104
|
+
recreated only inside the recorded location. Data with no recorded location
|
|
105
|
+
falls back to the link check. This matches Claude Code's checkpoint behavior.
|
|
106
|
+
- **Observation sync (best-effort)**: after a restore, the plugin re-emits the
|
|
107
|
+
host's `fs/observed` event for the paths it changed (absent, or present with
|
|
108
|
+
the current version), so a host with the observation policy enabled does not
|
|
109
|
+
judge the next write against a pre-restore observation. It heals only the
|
|
110
|
+
inconsistency the restore itself created; later external changes are still
|
|
111
|
+
seen normally.
|
|
123
112
|
- **Restores name only recorded paths**: the store contains resolved display
|
|
124
113
|
paths of the session's own write-class tool calls (plus boundary re-checks
|
|
125
114
|
over that same tracked set) — a restore can never write an arbitrary path.
|
|
126
|
-
- **Path resolution
|
|
127
|
-
working directory, read from the
|
|
128
|
-
- **Bounded backups**:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
removes whole **long-inactive** session directories (measured by the newest
|
|
134
|
-
member being idle past the configured `autoCleanupMaxAgeDays`); it uses
|
|
135
|
-
`lstat` (no symlink following), skips dot-prefixed temp files, and never
|
|
136
|
-
targets the active session (`keepActiveId`).
|
|
115
|
+
- **Path resolution**: relative paths resolve against the session's current
|
|
116
|
+
working directory, read from the service the fs tools resolve through.
|
|
117
|
+
- **Bounded backups**: the store keeps a bounded number of anchor groups per
|
|
118
|
+
session, except groups a non-terminal restore still references (pinned so the
|
|
119
|
+
operation can be finished); the exact cap is in
|
|
120
|
+
[the store format](docs/format.md). Across sessions, the opt-in sweep removes
|
|
121
|
+
whole long-inactive session directories and never targets the active session.
|
|
137
122
|
|
|
138
123
|
## Crash safety
|
|
139
124
|
|
|
140
|
-
- **Ordered commits**: a checkpoint
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
- **Journal IO never fails the restore**: if the journal cannot be written the
|
|
165
|
-
restore proceeds with pre-journal semantics (crash safety degrades,
|
|
166
|
-
behavior does not).
|
|
125
|
+
- **Ordered commits**: a checkpoint writes the before-sidecar (a raw byte copy)
|
|
126
|
+
before publishing the entry, so a crash can leave an unreferenced sidecar but
|
|
127
|
+
never an entry whose bytes are missing. Every JSON write goes to a sibling
|
|
128
|
+
temp file and is renamed over the target: a crash can leave only an inert
|
|
129
|
+
`.tmp`, never a readable half-written file, and readers never pick up temp
|
|
130
|
+
files.
|
|
131
|
+
- **Journaled restores**: before mutating anything, a restore captures each
|
|
132
|
+
planned path's pre-restore state and persists an intent journal, then marks
|
|
133
|
+
each action done as it is applied. A crash at any point leaves the journal on
|
|
134
|
+
disk.
|
|
135
|
+
- **Disk is truth**: an interrupted operation keeps its journal, and recovery
|
|
136
|
+
re-derives the true state from the real disk — which paths already match the
|
|
137
|
+
goal and which are pending, can finish the operation, and can undo it to the
|
|
138
|
+
exact pre-restore state. A journal whose goal is already reached auto-heals.
|
|
139
|
+
The plugin never runs recovery on its own: an interrupted operation stays
|
|
140
|
+
interrupted until an explicit caller resumes or undoes it.
|
|
141
|
+
- **Fail-loud vs fail-soft**: a corrupt journal is reported as
|
|
142
|
+
`recovery-required`, never silently dropped — dropping it would erase the
|
|
143
|
+
recovery record. Corrupt entries are ignored (they lose one backup, not the
|
|
144
|
+
recovery path), and a missing or truncated sidecar fails only that file —
|
|
145
|
+
never a delete of the live file.
|
|
146
|
+
- **Journal IO never fails the restore**: if the journal cannot be written, the
|
|
147
|
+
restore proceeds with pre-journal semantics; crash safety degrades, behavior
|
|
148
|
+
does not.
|
|
167
149
|
|
|
168
150
|
## Explicit non-goals
|
|
169
151
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
per-path deny), not a rewind feature.
|
|
190
|
-
- It does **not** touch git (no refs, index, or worktree operations), makes
|
|
191
|
-
**no network requests**, and does **not** access credentials.
|
|
192
|
-
- It does **not** roll back whole-log state: telemetry, search, and `/export`
|
|
193
|
-
still see the withdrawn messages (documented behavior, not a bug).
|
|
152
|
+
This section declares scope, not a gap list: each item names what stays outside
|
|
153
|
+
the plugin and what remains the host's or the user's responsibility. The
|
|
154
|
+
boundary contract is in [design boundaries](docs/boundaries.md).
|
|
155
|
+
|
|
156
|
+
- It does **not** sandbox other processes or stop them from changing files
|
|
157
|
+
concurrently.
|
|
158
|
+
- It does **not** provide confidentiality or tamper resistance against the same
|
|
159
|
+
OS user: **store** files use the process default permissions under the
|
|
160
|
+
process umask, and the host user remains trusted. Restored **workspace** files
|
|
161
|
+
carry their recorded permission bits (a content restore reapplies them,
|
|
162
|
+
best-effort), while a mode difference alone never triggers a restore.
|
|
163
|
+
- It does **not** exclude paths from the store, and it introduces no authority of
|
|
164
|
+
its own: sensitive files (e.g. a `.env` the model may edit) and files outside
|
|
165
|
+
the workspace the model may touch are backed up and restorable, unless over
|
|
166
|
+
the size cap. Keeping a path out is a **DSH model-permission** concern, not a
|
|
167
|
+
rewind feature.
|
|
168
|
+
- It does **not** touch git, make network requests, or access credentials.
|
|
169
|
+
- It does **not** roll back whole-log state: search, telemetry and `/export`
|
|
170
|
+
still see withdrawn messages (by design; see [boundaries](docs/boundaries.md)).
|
|
194
171
|
- It does **not** restore files written by a cancelled tool call that never
|
|
195
172
|
committed a backup.
|
|
196
|
-
- **Subagent session edits are not tracked
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
snapshot for a subagent session and offers no rewind inside one.
|
|
173
|
+
- **Subagent session edits are not tracked**: a subagent runs its own session,
|
|
174
|
+
so its files cannot be restored by a rewind of the parent, and the plugin
|
|
175
|
+
offers no rewind inside one. See [boundaries](docs/boundaries.md).
|
|
200
176
|
|
|
201
177
|
## Reporting
|
|
202
178
|
|
|
203
179
|
Report a vulnerability through the repository's GitHub security channel or to
|
|
204
|
-
a repository maintainer. Include
|
|
180
|
+
a repository maintainer. Include the plugin version/commit, the DSH
|
|
205
181
|
(`@deepseek-ai/*`) version, the platform, and a minimal reproduction — and
|
|
206
|
-
whether the failure happened before or after workspace mutation.
|
|
182
|
+
whether the failure happened before or after workspace mutation. Do not open a
|
|
183
|
+
public issue for a vulnerability.
|
package/lib/client.js
CHANGED
|
@@ -475,12 +475,13 @@ function waitForCommand(session, chatOf, match, timeoutMs = 8e3, watch) {
|
|
|
475
475
|
check();
|
|
476
476
|
});
|
|
477
477
|
}
|
|
478
|
-
var
|
|
478
|
+
var UNAVAILABLE = () => Promise.resolve({ kind: "error", text: "host query is unavailable" });
|
|
479
|
+
var previewCall = UNAVAILABLE;
|
|
479
480
|
function registerPreviewCall(call) {
|
|
480
|
-
previewCall = call;
|
|
481
|
+
previewCall = call ?? UNAVAILABLE;
|
|
481
482
|
}
|
|
482
483
|
function previewImpact(sessionId, seq) {
|
|
483
|
-
return previewCall
|
|
484
|
+
return previewCall(sessionId, seq);
|
|
484
485
|
}
|
|
485
486
|
function el(tag2, className, text) {
|
|
486
487
|
const node = document.createElement(tag2);
|
|
@@ -1530,12 +1531,18 @@ function createRewindBridge(deps) {
|
|
|
1530
1531
|
};
|
|
1531
1532
|
}
|
|
1532
1533
|
|
|
1533
|
-
// src/client/host-
|
|
1534
|
-
var CHANNEL = "/dsh-rewind";
|
|
1534
|
+
// src/client/host-api.ts
|
|
1535
1535
|
var TIMEOUT_MS = 8e3;
|
|
1536
1536
|
function isRecord(value) {
|
|
1537
1537
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1538
1538
|
}
|
|
1539
|
+
function isResult(value) {
|
|
1540
|
+
if (!isRecord(value)) return false;
|
|
1541
|
+
if (value["ok"] === true) return true;
|
|
1542
|
+
if (value["ok"] !== false || !isRecord(value["error"])) return false;
|
|
1543
|
+
const error = value["error"];
|
|
1544
|
+
return typeof error["code"] === "string" && typeof error["message"] === "string" && isRecord(error["details"]);
|
|
1545
|
+
}
|
|
1539
1546
|
function isImpact(value) {
|
|
1540
1547
|
return isRecord(value) && typeof value["path"] === "string" && (value["action"] === "restore" || value["action"] === "delete");
|
|
1541
1548
|
}
|
|
@@ -1546,53 +1553,67 @@ function isAbort(error) {
|
|
|
1546
1553
|
const name2 = error?.name;
|
|
1547
1554
|
return name2 === "AbortError" || name2 === "TimeoutError";
|
|
1548
1555
|
}
|
|
1549
|
-
|
|
1550
|
-
|
|
1556
|
+
function init(payload, signal) {
|
|
1557
|
+
return {
|
|
1558
|
+
method: "POST",
|
|
1559
|
+
headers: { "content-type": "application/json" },
|
|
1560
|
+
body: JSON.stringify(payload),
|
|
1561
|
+
signal
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
async function settleQuery(response, route) {
|
|
1565
|
+
if (!response.ok) throw new Error(`transport failure for ${route}: HTTP ${response.status}`);
|
|
1566
|
+
const result = await response.json();
|
|
1567
|
+
if (!isResult(result)) throw new Error(`invalid response for ${route}`);
|
|
1568
|
+
return result;
|
|
1569
|
+
}
|
|
1570
|
+
async function previewOverApi(sessionId, seq) {
|
|
1551
1571
|
try {
|
|
1552
|
-
const
|
|
1572
|
+
const response = await fetch("./api/dsh-rewind/preview", init({ sessionId, seq }, AbortSignal.timeout(TIMEOUT_MS)));
|
|
1573
|
+
const result = await settleQuery(response, "preview");
|
|
1553
1574
|
if (!result.ok) {
|
|
1554
|
-
rewindLog.warn("preview", `
|
|
1575
|
+
rewindLog.warn("preview", `preview request failed for @${seq}`, result.error);
|
|
1555
1576
|
return { kind: "error", text: result.error.message };
|
|
1556
1577
|
}
|
|
1557
1578
|
const value = result.value;
|
|
1558
1579
|
if (!isRecord(value) || typeof value["backupOff"] !== "boolean" || !Array.isArray(value["files"]) || !value["files"].every(isImpact)) {
|
|
1559
|
-
rewindLog.warn("preview", `
|
|
1580
|
+
rewindLog.warn("preview", `preview request returned an invalid result for @${seq}`);
|
|
1560
1581
|
return { kind: "error", text: "invalid preview response" };
|
|
1561
1582
|
}
|
|
1562
1583
|
return { kind: "success", files: value["files"], backupOff: value["backupOff"] };
|
|
1563
1584
|
} catch (error) {
|
|
1564
1585
|
if (isAbort(error)) {
|
|
1565
|
-
rewindLog.warn("preview", `
|
|
1586
|
+
rewindLog.warn("preview", `preview request timed out for @${seq}`);
|
|
1566
1587
|
return null;
|
|
1567
1588
|
}
|
|
1568
|
-
rewindLog.warn("preview", `
|
|
1589
|
+
rewindLog.warn("preview", `preview request threw for @${seq}`, error);
|
|
1569
1590
|
return { kind: "error", text: error instanceof Error ? error.message : String(error) };
|
|
1570
1591
|
}
|
|
1571
1592
|
}
|
|
1572
|
-
async function
|
|
1573
|
-
if (connection === void 0) return void 0;
|
|
1593
|
+
async function candidatesOverApi(sessionId) {
|
|
1574
1594
|
try {
|
|
1575
|
-
const
|
|
1595
|
+
const response = await fetch("./api/dsh-rewind/candidates", init({ sessionId }, AbortSignal.timeout(TIMEOUT_MS)));
|
|
1596
|
+
const result = await settleQuery(response, "candidates");
|
|
1576
1597
|
if (!result.ok) {
|
|
1577
|
-
rewindLog.warn("preview", "
|
|
1598
|
+
rewindLog.warn("preview", "candidate request failed", result.error);
|
|
1578
1599
|
return void 0;
|
|
1579
1600
|
}
|
|
1580
1601
|
const value = result.value;
|
|
1581
1602
|
if (!isRecord(value) || !Array.isArray(value["candidates"]) || !value["candidates"].every(isCandidate)) {
|
|
1582
|
-
rewindLog.warn("preview", "
|
|
1603
|
+
rewindLog.warn("preview", "candidate request returned an invalid result");
|
|
1583
1604
|
return void 0;
|
|
1584
1605
|
}
|
|
1585
1606
|
return value["candidates"];
|
|
1586
1607
|
} catch (error) {
|
|
1587
|
-
rewindLog.warn("preview", "
|
|
1608
|
+
rewindLog.warn("preview", "candidate request threw", error);
|
|
1588
1609
|
return void 0;
|
|
1589
1610
|
}
|
|
1590
1611
|
}
|
|
1591
1612
|
|
|
1592
1613
|
// src/client/build-info.ts
|
|
1593
|
-
var PLUGIN_VERSION = true ? "0.20.0
|
|
1614
|
+
var PLUGIN_VERSION = true ? "0.20.0" : "dev";
|
|
1594
1615
|
var PLUGIN_PACKAGE = "dsh-rewind-plugin";
|
|
1595
|
-
var BUILD_HASH = true ? "
|
|
1616
|
+
var BUILD_HASH = true ? "c59b80ee" : "dev";
|
|
1596
1617
|
|
|
1597
1618
|
// src/client/locales.ts
|
|
1598
1619
|
var en = {
|
|
@@ -2063,7 +2084,7 @@ function SettingsCard(props) {
|
|
|
2063
2084
|
|
|
2064
2085
|
// src/client/index.ts
|
|
2065
2086
|
var name = "dsh-rewind";
|
|
2066
|
-
var inject = ["slots", "sessions", "locale", "commandUi", "configForms"
|
|
2087
|
+
var inject = ["slots", "sessions", "locale", "commandUi", "configForms"];
|
|
2067
2088
|
var NS2 = "rewind";
|
|
2068
2089
|
var HEADER_ACTIONS_SLOT = "conversation.session.header.actions";
|
|
2069
2090
|
var COMPOSER_EDITABLE_SELECTOR2 = "[data-composer-input]";
|
|
@@ -2072,8 +2093,7 @@ function apply(ctx) {
|
|
|
2072
2093
|
ctx.effect(function* () {
|
|
2073
2094
|
yield ctx.locale.register(NS2, CLIENT_DICTS);
|
|
2074
2095
|
const t = ctx.locale.bind(NS2);
|
|
2075
|
-
|
|
2076
|
-
registerPreviewCall((sessionId, seq) => previewOverRpc(connection, sessionId, seq));
|
|
2096
|
+
registerPreviewCall(previewOverApi);
|
|
2077
2097
|
yield () => {
|
|
2078
2098
|
registerPreviewCall(null);
|
|
2079
2099
|
};
|
|
@@ -2227,7 +2247,7 @@ function apply(ctx) {
|
|
|
2227
2247
|
const chat = chatOf(face);
|
|
2228
2248
|
return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
|
|
2229
2249
|
};
|
|
2230
|
-
const fetchHostCandidates = async (face) =>
|
|
2250
|
+
const fetchHostCandidates = async (face) => candidatesOverApi(face.sessionId);
|
|
2231
2251
|
const hostCandidatesCache = /* @__PURE__ */ new Map();
|
|
2232
2252
|
const composerAnchor = () => {
|
|
2233
2253
|
const surface = composerSurface2();
|
package/lib/index.js
CHANGED
|
@@ -2583,7 +2583,8 @@ function shouldRunAutoSweep(lastAtMs, nowMs, intervalMs = AUTO_SWEEP_INTERVAL_MS
|
|
|
2583
2583
|
var name = "dsh-rewind";
|
|
2584
2584
|
var inject = ["commands", "tools", "settings"];
|
|
2585
2585
|
var ENGINE_COMMAND = "rewind-plugin";
|
|
2586
|
-
var
|
|
2586
|
+
var REWIND_PREVIEW_PATH = "/api/dsh-rewind/preview";
|
|
2587
|
+
var REWIND_CANDIDATES_PATH = "/api/dsh-rewind/candidates";
|
|
2587
2588
|
var DEFAULT_FILE_CHECKPOINTING_ENABLED = true;
|
|
2588
2589
|
var Config = z.object({
|
|
2589
2590
|
snapshotDir: z.string().required(false),
|
|
@@ -2776,36 +2777,45 @@ async function previewImpact(store, session, seq, checkpointingEnabled) {
|
|
|
2776
2777
|
function isRecord(value) {
|
|
2777
2778
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2778
2779
|
}
|
|
2779
|
-
function
|
|
2780
|
+
function queryFailure(code, message) {
|
|
2780
2781
|
return { ok: false, error: { code, message, details: {} } };
|
|
2781
2782
|
}
|
|
2782
|
-
function
|
|
2783
|
-
return
|
|
2783
|
+
function queryResponse(result) {
|
|
2784
|
+
return Response.json(result);
|
|
2785
|
+
}
|
|
2786
|
+
function createRewindQueryHandler(store, agentsOf, checkpointingEnabled, query) {
|
|
2787
|
+
return async (request) => {
|
|
2784
2788
|
try {
|
|
2785
|
-
if (
|
|
2789
|
+
if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") {
|
|
2790
|
+
return new Response("content type must be application/json", { status: 415 });
|
|
2791
|
+
}
|
|
2792
|
+
let payload;
|
|
2793
|
+
try {
|
|
2794
|
+
payload = await request.json();
|
|
2795
|
+
} catch {
|
|
2796
|
+
return new Response("body is not JSON", { status: 400 });
|
|
2797
|
+
}
|
|
2798
|
+
if (!isRecord(payload)) return queryResponse(queryFailure("rewind/bad-request", "invalid payload"));
|
|
2786
2799
|
const sessionId = payload["sessionId"];
|
|
2787
2800
|
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
2788
|
-
return
|
|
2801
|
+
return queryResponse(queryFailure("rewind/bad-request", "sessionId must be a non-empty string"));
|
|
2789
2802
|
}
|
|
2790
2803
|
const agents = agentsOf();
|
|
2791
|
-
if (agents === void 0) return
|
|
2804
|
+
if (agents === void 0) return queryResponse(queryFailure("rewind/agents-unavailable", "agent registry is unavailable"));
|
|
2792
2805
|
const agent = agents.get(sessionId);
|
|
2793
|
-
if (agent === void 0) return
|
|
2806
|
+
if (agent === void 0) return queryResponse(queryFailure("rewind/session-not-found", `session ${sessionId} is not attached`));
|
|
2794
2807
|
const session = agent.session;
|
|
2795
|
-
if (
|
|
2808
|
+
if (query === "preview") {
|
|
2796
2809
|
const seq = payload["seq"];
|
|
2797
|
-
if (!Number.isSafeInteger(seq)) return
|
|
2798
|
-
return { ok: true, value: await previewImpact(store, session, seq, checkpointingEnabled()) };
|
|
2799
|
-
}
|
|
2800
|
-
if (endpoint === "candidates") {
|
|
2801
|
-
return {
|
|
2802
|
-
ok: true,
|
|
2803
|
-
value: { candidates: listRewindCandidates(session.snapshotEvents(), session.surface.nodes) }
|
|
2804
|
-
};
|
|
2810
|
+
if (!Number.isSafeInteger(seq)) return queryResponse(queryFailure("rewind/bad-request", "seq must be an integer"));
|
|
2811
|
+
return queryResponse({ ok: true, value: await previewImpact(store, session, seq, checkpointingEnabled()) });
|
|
2805
2812
|
}
|
|
2806
|
-
return
|
|
2813
|
+
return queryResponse({
|
|
2814
|
+
ok: true,
|
|
2815
|
+
value: { candidates: listRewindCandidates(session.snapshotEvents(), session.surface.nodes) }
|
|
2816
|
+
});
|
|
2807
2817
|
} catch (error) {
|
|
2808
|
-
return
|
|
2818
|
+
return queryResponse(queryFailure("rewind/failed", error instanceof Error ? error.message : String(error)));
|
|
2809
2819
|
}
|
|
2810
2820
|
};
|
|
2811
2821
|
}
|
|
@@ -3244,21 +3254,24 @@ function apply(ctx, config) {
|
|
|
3244
3254
|
})();
|
|
3245
3255
|
}, { global: true });
|
|
3246
3256
|
ctx.inject(["connection"], (scope) => {
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3257
|
+
const connection = scope.connection;
|
|
3258
|
+
if (connection === void 0) return;
|
|
3259
|
+
const agentsOf = () => scope.get("agents");
|
|
3260
|
+
const checkpointingEnabled = () => isFileCheckpointingEnabled(config);
|
|
3261
|
+
const register = (path, query) => {
|
|
3262
|
+
try {
|
|
3263
|
+
scope.effect(() => connection.fetch.register({
|
|
3264
|
+
path,
|
|
3265
|
+
methods: ["POST"],
|
|
3266
|
+
requestBody: "buffered",
|
|
3267
|
+
fetch: createRewindQueryHandler(store, agentsOf, checkpointingEnabled, query)
|
|
3268
|
+
}), `dsh-rewind ${path} route`);
|
|
3269
|
+
} catch (error) {
|
|
3270
|
+
ctx.logger.error(`[dsh-rewind] route registration failed for ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3271
|
+
}
|
|
3272
|
+
};
|
|
3273
|
+
register(REWIND_PREVIEW_PATH, "preview");
|
|
3274
|
+
register(REWIND_CANDIDATES_PATH, "candidates");
|
|
3262
3275
|
});
|
|
3263
3276
|
ctx.inject(["fs", "workingDirectory"], (scope) => {
|
|
3264
3277
|
const fs = scope.fs;
|
|
@@ -74,7 +74,8 @@ export declare function rewindCandidatesOf(snap: CandidateChat, limit?: number):
|
|
|
74
74
|
export declare function rewindCandidatesOfChat(snap: CandidateChat): RewindCandidate[];
|
|
75
75
|
/**
|
|
76
76
|
* Map typed candidates to popupSelect rows. The list comes from the host over
|
|
77
|
-
*
|
|
77
|
+
* the host query route, so it covers the FULL host surface rather than the
|
|
78
|
+
* windowed chat snapshot.
|
|
78
79
|
*/
|
|
79
80
|
export declare function rewindOptionsFromCandidates(candidates: readonly RewindCandidate[], t: Translate): SelectOption[];
|
|
80
81
|
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser half of the two read-only host queries: the mode popover's impact
|
|
3
|
+
* preview and the `/rewind` candidate list, posted to their exact `/api` routes.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-rewind/client/host-api
|
|
6
|
+
*/
|
|
7
|
+
import type { RewindCandidate } from './candidates.ts';
|
|
8
|
+
/** One file affected by a rewind. */
|
|
9
|
+
export interface PreviewFileImpact {
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly action: 'restore' | 'delete';
|
|
12
|
+
}
|
|
13
|
+
/** One impact probe resolved, failed, or timed out (`null`). */
|
|
14
|
+
export type PreviewOutcome = {
|
|
15
|
+
readonly kind: 'success';
|
|
16
|
+
readonly files: readonly PreviewFileImpact[];
|
|
17
|
+
readonly backupOff: boolean;
|
|
18
|
+
} | {
|
|
19
|
+
readonly kind: 'error';
|
|
20
|
+
readonly text: string;
|
|
21
|
+
} | null;
|
|
22
|
+
/** Fetch one target's impact through the host query route. */
|
|
23
|
+
export type PreviewCall = (sessionId: string, seq: number) => Promise<PreviewOutcome>;
|
|
24
|
+
/** Resolve one target's file impact through the host query route. */
|
|
25
|
+
export declare function previewOverApi(sessionId: string, seq: number): Promise<PreviewOutcome>;
|
|
26
|
+
/** Fetch the full rewind candidate list through the host query route. */
|
|
27
|
+
export declare function candidatesOverApi(sessionId: string): Promise<readonly RewindCandidate[] | undefined>;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* The command decoration (`ctx.commandUi.decorate`) makes a bare `/rewind` (or
|
|
10
10
|
* its alias `/undo`) open the harness's own popupSelect instead of executing;
|
|
11
11
|
* the parameterized `@<seq>` form stays the execution channel, while the
|
|
12
|
-
* popover's impact and candidate reads go over the host
|
|
12
|
+
* popover's impact and candidate reads go over the host's exact `/api` routes.
|
|
13
13
|
*
|
|
14
14
|
* @module dsh-rewind/client
|
|
15
15
|
*/
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The rewind mode-selection popover (plain DOM, no React). Step two of the
|
|
3
3
|
* interaction: the target is already fixed (the clicked message); the popover
|
|
4
4
|
* offers the two modes. Choosing "both" fetches the impact list over the host
|
|
5
|
-
*
|
|
5
|
+
* `/api` preview route and shows it before confirming.
|
|
6
6
|
*
|
|
7
7
|
* Keyboard: ↑/↓ move focus across the step's ACTION buttons only (the two
|
|
8
8
|
* modes, or the confirm button on the impact step), Enter activates the
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client';
|
|
17
17
|
import type { CommandNode } from '@deepseek-ai/dsh-client-ui-conversation/client';
|
|
18
18
|
import { type ChatOf } from './hidden.ts';
|
|
19
|
-
import type { PreviewCall } from './host-
|
|
19
|
+
import type { PreviewCall } from './host-api.ts';
|
|
20
20
|
import type { RewindKey } from './locales.ts';
|
|
21
21
|
type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
|
|
22
22
|
export interface PopoverOptions {
|
|
@@ -66,7 +66,7 @@ export declare function waitForCommand(session: SessionFace, chatOf: ChatOf, mat
|
|
|
66
66
|
kind: 'success' | 'error';
|
|
67
67
|
text?: string;
|
|
68
68
|
} | null>;
|
|
69
|
-
/** Register the host
|
|
69
|
+
/** Register the host preview call; null restores the unavailable default. */
|
|
70
70
|
export declare function registerPreviewCall(call: PreviewCall | null): void;
|
|
71
71
|
/** Open the mode-selection popover anchored near the given button. */
|
|
72
72
|
export declare function openPopover(opts: PopoverOptions): void;
|
package/lib/types/locales.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* The client half (`src/client/locales.ts`) owns all interactive UI copy via
|
|
18
18
|
* `ctx.locale` + `t()`; the client never parses host prose (the popover reads
|
|
19
|
-
* travel over the
|
|
19
|
+
* travel over the host `/api` query routes).
|
|
20
20
|
*
|
|
21
21
|
* DSH localizes only its OWN command descriptions via the client locale — a
|
|
22
22
|
* closed `HOST_FACES` allowlist; third-party plugin descriptions are
|
package/lib/types/snapshot.d.ts
CHANGED
|
@@ -179,7 +179,7 @@ export interface LinkEntry {
|
|
|
179
179
|
export type StoredEntry = CheckpointEntry | LinkEntry;
|
|
180
180
|
/** True when an entry is a dedup link (carries `ref`, not `before`). */
|
|
181
181
|
export declare function isLinkEntry(entry: StoredEntry): entry is LinkEntry;
|
|
182
|
-
/** Per-file restore impact preview (served by the host
|
|
182
|
+
/** Per-file restore impact preview (served by the host preview route). */
|
|
183
183
|
export interface FileImpact {
|
|
184
184
|
readonly path: string;
|
|
185
185
|
/** `restore` = write the before content back; `delete` = remove the file. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.20.0
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "In-window conversation rewind with workspace file restore",
|
|
5
5
|
"icon": "./assets/icon.svg",
|
|
6
6
|
"keywords": [
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"engines": {
|
|
25
25
|
"node": ">=24.9.0",
|
|
26
|
-
"dsh": ">=0.2.2-
|
|
26
|
+
"dsh": ">=0.2.2-rc.1 <0.3.0-0"
|
|
27
27
|
},
|
|
28
28
|
"type": "module",
|
|
29
29
|
"main": "lib/index.js",
|
|
@@ -58,7 +58,6 @@
|
|
|
58
58
|
"client": {
|
|
59
59
|
"inject": [
|
|
60
60
|
"@deepseek-ai/dsh-client-locale",
|
|
61
|
-
"@deepseek-ai/dsh-client-connection",
|
|
62
61
|
"@deepseek-ai/dsh-client-ui-commands",
|
|
63
62
|
"@deepseek-ai/dsh-client-ui-conversation",
|
|
64
63
|
"@deepseek-ai/dsh-client-ui-settings"
|
|
@@ -79,24 +78,24 @@
|
|
|
79
78
|
"access": "public"
|
|
80
79
|
},
|
|
81
80
|
"peerDependencies": {
|
|
82
|
-
"@deepseek-ai/cordis": "^4.0
|
|
83
|
-
"@deepseek-ai/dsh-client-connection": ">=0.2.2-
|
|
84
|
-
"@deepseek-ai/dsh-client-locale": ">=0.2.2-
|
|
85
|
-
"@deepseek-ai/dsh-client-ui-commands": ">=0.2.2-
|
|
86
|
-
"@deepseek-ai/dsh-client-ui-plugin-manager": ">=0.2.2-
|
|
87
|
-
"@deepseek-ai/dsh-client-ui-primitives": ">=0.2.2-
|
|
88
|
-
"@deepseek-ai/dsh-client-ui-settings": ">=0.2.2-
|
|
89
|
-
"@deepseek-ai/dsh-client-ui-slots": ">=0.2.2-
|
|
90
|
-
"@deepseek-ai/dsh-commands": ">=0.2.2-
|
|
91
|
-
"@deepseek-ai/dsh-fs": ">=0.2.2-
|
|
92
|
-
"@deepseek-ai/dsh-home-paths": ">=0.2.2-
|
|
93
|
-
"@deepseek-ai/dsh-llm": ">=0.2.2-
|
|
94
|
-
"@deepseek-ai/dsh-sandbox": ">=0.2.2-
|
|
95
|
-
"@deepseek-ai/dsh-session": ">=0.2.2-
|
|
96
|
-
"@deepseek-ai/dsh-settings": ">=0.2.2-
|
|
97
|
-
"@deepseek-ai/dsh-tools": ">=0.2.2-
|
|
98
|
-
"@deepseek-ai/dsh-working-directory": ">=0.2.2-
|
|
99
|
-
"@deepseek-ai/schemastery": "^3.
|
|
81
|
+
"@deepseek-ai/cordis": "^4.1.0",
|
|
82
|
+
"@deepseek-ai/dsh-client-connection": ">=0.2.2-rc.1 <0.3.0-0",
|
|
83
|
+
"@deepseek-ai/dsh-client-locale": ">=0.2.2-rc.1 <0.3.0-0",
|
|
84
|
+
"@deepseek-ai/dsh-client-ui-commands": ">=0.2.2-rc.1 <0.3.0-0",
|
|
85
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager": ">=0.2.2-rc.1 <0.3.0-0",
|
|
86
|
+
"@deepseek-ai/dsh-client-ui-primitives": ">=0.2.2-rc.1 <0.3.0-0",
|
|
87
|
+
"@deepseek-ai/dsh-client-ui-settings": ">=0.2.2-rc.1 <0.3.0-0",
|
|
88
|
+
"@deepseek-ai/dsh-client-ui-slots": ">=0.2.2-rc.1 <0.3.0-0",
|
|
89
|
+
"@deepseek-ai/dsh-commands": ">=0.2.2-rc.1 <0.3.0-0",
|
|
90
|
+
"@deepseek-ai/dsh-fs": ">=0.2.2-rc.1 <0.3.0-0",
|
|
91
|
+
"@deepseek-ai/dsh-home-paths": ">=0.2.2-rc.1 <0.3.0-0",
|
|
92
|
+
"@deepseek-ai/dsh-llm": ">=0.2.2-rc.1 <0.3.0-0",
|
|
93
|
+
"@deepseek-ai/dsh-sandbox": ">=0.2.2-rc.1 <0.3.0-0",
|
|
94
|
+
"@deepseek-ai/dsh-session": ">=0.2.2-rc.1 <0.3.0-0",
|
|
95
|
+
"@deepseek-ai/dsh-settings": ">=0.2.2-rc.1 <0.3.0-0",
|
|
96
|
+
"@deepseek-ai/dsh-tools": ">=0.2.2-rc.1 <0.3.0-0",
|
|
97
|
+
"@deepseek-ai/dsh-working-directory": ">=0.2.2-rc.1 <0.3.0-0",
|
|
98
|
+
"@deepseek-ai/schemastery": "^3.19.0"
|
|
100
99
|
},
|
|
101
100
|
"peerDependenciesMeta": {
|
|
102
101
|
"@deepseek-ai/cordis": {
|
|
@@ -155,45 +154,45 @@
|
|
|
155
154
|
}
|
|
156
155
|
},
|
|
157
156
|
"devDependencies": {
|
|
158
|
-
"@deepseek-ai/cordis": "4.0
|
|
159
|
-
"@deepseek-ai/cosmokit": "1.
|
|
160
|
-
"@deepseek-ai/dsh-agent": "0.2.2-
|
|
161
|
-
"@deepseek-ai/dsh-api-session-controller": "0.2.2-
|
|
162
|
-
"@deepseek-ai/dsh-attachment": "0.2.2-
|
|
163
|
-
"@deepseek-ai/dsh-client-connection": "0.2.2-
|
|
164
|
-
"@deepseek-ai/dsh-client-locale": "0.2.2-
|
|
165
|
-
"@deepseek-ai/dsh-client-store": "0.2.2-
|
|
166
|
-
"@deepseek-ai/dsh-client-ui-commands": "0.2.2-
|
|
167
|
-
"@deepseek-ai/dsh-client-ui-conversation": "0.2.2-
|
|
168
|
-
"@deepseek-ai/dsh-client-ui-input-trigger": "0.2.2-
|
|
169
|
-
"@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.2-
|
|
170
|
-
"@deepseek-ai/dsh-client-ui-primitives": "0.2.2-
|
|
171
|
-
"@deepseek-ai/dsh-client-ui-session": "0.2.2-
|
|
172
|
-
"@deepseek-ai/dsh-client-ui-settings": "0.2.2-
|
|
173
|
-
"@deepseek-ai/dsh-client-ui-slots": "0.2.2-
|
|
174
|
-
"@deepseek-ai/dsh-command-compact": "0.2.2-
|
|
175
|
-
"@deepseek-ai/dsh-commands": "0.2.2-
|
|
176
|
-
"@deepseek-ai/dsh-compaction": "0.2.2-
|
|
177
|
-
"@deepseek-ai/dsh-compaction-basic": "0.2.2-
|
|
178
|
-
"@deepseek-ai/dsh-compaction-image-offload": "0.2.2-
|
|
179
|
-
"@deepseek-ai/dsh-fs": "0.2.2-
|
|
180
|
-
"@deepseek-ai/dsh-goal": "0.2.2-
|
|
181
|
-
"@deepseek-ai/dsh-home-paths": "0.2.2-
|
|
182
|
-
"@deepseek-ai/dsh-llm": "0.2.2-
|
|
183
|
-
"@deepseek-ai/dsh-plan-mode": "0.2.2-
|
|
184
|
-
"@deepseek-ai/dsh-sandbox": "0.2.2-
|
|
185
|
-
"@deepseek-ai/dsh-scope": "0.2.2-
|
|
186
|
-
"@deepseek-ai/dsh-session": "0.2.2-
|
|
187
|
-
"@deepseek-ai/dsh-session-projection": "0.2.2-
|
|
188
|
-
"@deepseek-ai/dsh-session-stats": "0.2.2-
|
|
189
|
-
"@deepseek-ai/dsh-session-title": "0.2.2-
|
|
190
|
-
"@deepseek-ai/dsh-settings": "0.2.2-
|
|
191
|
-
"@deepseek-ai/dsh-token-meter": "0.2.2-
|
|
192
|
-
"@deepseek-ai/dsh-tools": "0.2.2-
|
|
193
|
-
"@deepseek-ai/dsh-typert-protocol": "0.2.2-
|
|
194
|
-
"@deepseek-ai/dsh-user-questions": "0.2.2-
|
|
195
|
-
"@deepseek-ai/dsh-working-directory": "0.2.2-
|
|
196
|
-
"@deepseek-ai/schemastery": "3.
|
|
157
|
+
"@deepseek-ai/cordis": "4.1.0",
|
|
158
|
+
"@deepseek-ai/cosmokit": "1.9.0",
|
|
159
|
+
"@deepseek-ai/dsh-agent": "0.2.2-rc.1",
|
|
160
|
+
"@deepseek-ai/dsh-api-session-controller": "0.2.2-rc.1",
|
|
161
|
+
"@deepseek-ai/dsh-attachment": "0.2.2-rc.1",
|
|
162
|
+
"@deepseek-ai/dsh-client-connection": "0.2.2-rc.1",
|
|
163
|
+
"@deepseek-ai/dsh-client-locale": "0.2.2-rc.1",
|
|
164
|
+
"@deepseek-ai/dsh-client-store": "0.2.2-rc.1",
|
|
165
|
+
"@deepseek-ai/dsh-client-ui-commands": "0.2.2-rc.1",
|
|
166
|
+
"@deepseek-ai/dsh-client-ui-conversation": "0.2.2-rc.1",
|
|
167
|
+
"@deepseek-ai/dsh-client-ui-input-trigger": "0.2.2-rc.1",
|
|
168
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.2-rc.1",
|
|
169
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.2.2-rc.1",
|
|
170
|
+
"@deepseek-ai/dsh-client-ui-session": "0.2.2-rc.1",
|
|
171
|
+
"@deepseek-ai/dsh-client-ui-settings": "0.2.2-rc.1",
|
|
172
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.2.2-rc.1",
|
|
173
|
+
"@deepseek-ai/dsh-command-compact": "0.2.2-rc.1",
|
|
174
|
+
"@deepseek-ai/dsh-commands": "0.2.2-rc.1",
|
|
175
|
+
"@deepseek-ai/dsh-compaction": "0.2.2-rc.1",
|
|
176
|
+
"@deepseek-ai/dsh-compaction-basic": "0.2.2-rc.1",
|
|
177
|
+
"@deepseek-ai/dsh-compaction-image-offload": "0.2.2-rc.1",
|
|
178
|
+
"@deepseek-ai/dsh-fs": "0.2.2-rc.1",
|
|
179
|
+
"@deepseek-ai/dsh-goal": "0.2.2-rc.1",
|
|
180
|
+
"@deepseek-ai/dsh-home-paths": "0.2.2-rc.1",
|
|
181
|
+
"@deepseek-ai/dsh-llm": "0.2.2-rc.1",
|
|
182
|
+
"@deepseek-ai/dsh-plan-mode": "0.2.2-rc.1",
|
|
183
|
+
"@deepseek-ai/dsh-sandbox": "0.2.2-rc.1",
|
|
184
|
+
"@deepseek-ai/dsh-scope": "0.2.2-rc.1",
|
|
185
|
+
"@deepseek-ai/dsh-session": "0.2.2-rc.1",
|
|
186
|
+
"@deepseek-ai/dsh-session-projection": "0.2.2-rc.1",
|
|
187
|
+
"@deepseek-ai/dsh-session-stats": "0.2.2-rc.1",
|
|
188
|
+
"@deepseek-ai/dsh-session-title": "0.2.2-rc.1",
|
|
189
|
+
"@deepseek-ai/dsh-settings": "0.2.2-rc.1",
|
|
190
|
+
"@deepseek-ai/dsh-token-meter": "0.2.2-rc.1",
|
|
191
|
+
"@deepseek-ai/dsh-tools": "0.2.2-rc.1",
|
|
192
|
+
"@deepseek-ai/dsh-typert-protocol": "0.2.2-rc.1",
|
|
193
|
+
"@deepseek-ai/dsh-user-questions": "0.2.2-rc.1",
|
|
194
|
+
"@deepseek-ai/dsh-working-directory": "0.2.2-rc.1",
|
|
195
|
+
"@deepseek-ai/schemastery": "3.19.0",
|
|
197
196
|
"@types/node": "^24.0.0",
|
|
198
197
|
"@types/react": "^18.3.31",
|
|
199
198
|
"@types/react-dom": "^18.3.7",
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The browser half of the rewind RPC channel: the mode popover's impact
|
|
3
|
-
* preview and the `/rewind` candidate list. Both are read-only host queries,
|
|
4
|
-
* so they travel over Connection RPC instead of the human command channel.
|
|
5
|
-
*
|
|
6
|
-
* @module dsh-rewind/client/host-rpc
|
|
7
|
-
*/
|
|
8
|
-
import type { RewindCandidate } from './candidates.ts';
|
|
9
|
-
/** One file affected by a rewind. */
|
|
10
|
-
export interface PreviewFileImpact {
|
|
11
|
-
readonly path: string;
|
|
12
|
-
readonly action: 'restore' | 'delete';
|
|
13
|
-
}
|
|
14
|
-
/** One impact probe resolved, failed, or timed out (`null`). */
|
|
15
|
-
export type PreviewOutcome = {
|
|
16
|
-
readonly kind: 'success';
|
|
17
|
-
readonly files: readonly PreviewFileImpact[];
|
|
18
|
-
readonly backupOff: boolean;
|
|
19
|
-
} | {
|
|
20
|
-
readonly kind: 'error';
|
|
21
|
-
readonly text: string;
|
|
22
|
-
} | null;
|
|
23
|
-
/** Fetch one target's impact through the registered RPC call. */
|
|
24
|
-
export type PreviewCall = (sessionId: string, seq: number) => Promise<PreviewOutcome>;
|
|
25
|
-
type RpcResult = {
|
|
26
|
-
readonly ok: true;
|
|
27
|
-
readonly value: unknown;
|
|
28
|
-
} | {
|
|
29
|
-
readonly ok: false;
|
|
30
|
-
readonly error: {
|
|
31
|
-
readonly code: string;
|
|
32
|
-
readonly message: string;
|
|
33
|
-
};
|
|
34
|
-
};
|
|
35
|
-
/** The slice of the client `connection` service this module reads. */
|
|
36
|
-
export interface ConnectionLike {
|
|
37
|
-
readonly rpc: {
|
|
38
|
-
call(channel: string, endpoint: string, payload: unknown, signal?: AbortSignal): Promise<RpcResult>;
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
/** Resolve one target's file impact over RPC. */
|
|
42
|
-
export declare function previewOverRpc(connection: ConnectionLike | undefined, sessionId: string, seq: number): Promise<PreviewOutcome>;
|
|
43
|
-
/** Fetch the full rewind candidate list over RPC. */
|
|
44
|
-
export declare function candidatesOverRpc(connection: ConnectionLike | undefined, sessionId: string): Promise<readonly RewindCandidate[] | undefined>;
|
|
45
|
-
export {};
|