dsh-rewind-plugin 0.15.0 → 0.15.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.md +1 -1
- package/lib/client.js +1 -1
- package/package.json +38 -40
- package/CONTRIBUTING.md +0 -73
- package/assets/screenshots/add-plugin.png +0 -0
- package/assets/screenshots/cleanup-setting.png +0 -0
- package/assets/screenshots/impact-list.png +0 -0
- package/assets/screenshots/mode-popover.png +0 -0
- package/assets/screenshots/rewind-button.png +0 -0
- package/assets/screenshots/rewind-candidates.png +0 -0
- package/docs/README.md +0 -33
- package/docs/architecture.md +0 -145
- package/docs/compat/audit.md +0 -170
- package/docs/compat/diagnostics.md +0 -83
- package/docs/compat/diagnostics.zh.md +0 -61
- package/docs/compat/tracking-boundary.md +0 -217
- package/docs/compat/tracking-boundary.zh.md +0 -131
- package/docs/contract/client-contract.md +0 -58
- package/docs/contract/client-contract.zh.md +0 -54
- package/docs/format.md +0 -271
- package/docs/harness-reference.md +0 -75
- package/docs/release/release.md +0 -136
- package/docs/release/release.zh.md +0 -124
- package/docs/rewind-fix.md +0 -89
- package/docs/rewind-fix.zh.md +0 -89
- package/docs/snapshot-auto-cleanup.md +0 -78
- package/docs/snapshot-auto-cleanup.zh.md +0 -48
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
# Browser diagnostics
|
|
2
|
-
|
|
3
|
-
[简体中文](diagnostics.zh.md)
|
|
4
|
-
|
|
5
|
-
Every browser-side diagnostic this plugin emits is routed through one small,
|
|
6
|
-
level-filtered channel, so an anomaly surfaces under a single console filter.
|
|
7
|
-
The channel is deliberately narrow: it records only genuine internal anomalies
|
|
8
|
-
the plugin itself detects as off-contract — **not** the normal-path facts the
|
|
9
|
-
user already sees on screen, which carry no attribution.
|
|
10
|
-
|
|
11
|
-
## Always-on anomaly alerts
|
|
12
|
-
|
|
13
|
-
`error` and `warn` are always printed (no switch, no configuration) so a fault
|
|
14
|
-
surfaces even for a user who never configured anything:
|
|
15
|
-
|
|
16
|
-
| Level | Meaning |
|
|
17
|
-
| --- | --- |
|
|
18
|
-
| `error` | Unexpected/breaking failure (e.g. the settings card fails to register) |
|
|
19
|
-
| `warn` | Recoverable anomaly guard (e.g. a rewind command/refill throws, a waited-on outcome never settles, an unmet session binding) |
|
|
20
|
-
|
|
21
|
-
These cases are rare, cheap, and off-contract. Normal-path detail (which
|
|
22
|
-
branch a refill took, whether a write matched, which rows a rewind cut) is
|
|
23
|
-
deliberately **not** logged: it re-states what the user already sees and adds
|
|
24
|
-
nothing to attribution.
|
|
25
|
-
|
|
26
|
-
## Scopes
|
|
27
|
-
|
|
28
|
-
Diagnostics are printed as `[dsh-rewind:<scope>] ...`. The scope names the
|
|
29
|
-
subsystem an anomaly belongs to, so a report can be captured with a single
|
|
30
|
-
console filter:
|
|
31
|
-
|
|
32
|
-
| Scope | What an anomaly here means |
|
|
33
|
-
| --- | --- |
|
|
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, 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) |
|
|
37
|
-
| `portals` | Per-message button mount issues (e.g. no session binding) |
|
|
38
|
-
| `retract` | The pending-steering retract path (the remove lost the race, or the remove threw) |
|
|
39
|
-
| `settings` | The snapshot-cleanup settings card |
|
|
40
|
-
|
|
41
|
-
## Verbose switch
|
|
42
|
-
|
|
43
|
-
The `info`/`debug` levels are gated by `localStorage['dsh-rewind.debug']`, read
|
|
44
|
-
once per call and filtered by namespace. Two gated lines remain: the `boot`
|
|
45
|
-
startup identity line and the `retract` lost-race line. Both are deliberately
|
|
46
|
-
**off** by default (a normal user's console stays clean, and neither is an
|
|
47
|
-
anomaly), so a reporter enables the scope to see them:
|
|
48
|
-
|
|
49
|
-
```js
|
|
50
|
-
// Just the startup identity line.
|
|
51
|
-
localStorage['dsh-rewind.debug'] = 'dsh-rewind:boot'
|
|
52
|
-
|
|
53
|
-
// Or everything (avoids needing an exact scope).
|
|
54
|
-
localStorage['dsh-rewind.debug'] = 'dsh-rewind*'
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Reload (`F5`) after setting it. To switch it off:
|
|
58
|
-
|
|
59
|
-
```js
|
|
60
|
-
delete localStorage['dsh-rewind.debug']
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
The `error`/`warn` anomaly alerts are **not** gated by this switch — they are
|
|
64
|
-
always printed.
|
|
65
|
-
|
|
66
|
-
## Capturing a report
|
|
67
|
-
|
|
68
|
-
1. Reproduce once on the affected page.
|
|
69
|
-
2. In DevTools, filter the Console for `dsh-rewind` and copy the output
|
|
70
|
-
(with the plugin version and the DSH/kernel version).
|
|
71
|
-
|
|
72
|
-
The `error`/`warn` anomaly alerts require no setup — they always print. To also
|
|
73
|
-
capture the startup identity line (`boot`) — useful for ruling out a stale
|
|
74
|
-
bundle / un-restarted host — enable the verbose switch first (see above), then
|
|
75
|
-
reload.
|
|
76
|
-
|
|
77
|
-
## Notes
|
|
78
|
-
|
|
79
|
-
- This diagnostic surface is an aid for maintainers and cooperating reporters,
|
|
80
|
-
**not** a stable public interface; its exact keys and output may change
|
|
81
|
-
without notice.
|
|
82
|
-
- It is scoped to one browser origin and one browser; capture where the problem
|
|
83
|
-
actually occurs.
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
# 浏览器诊断
|
|
2
|
-
|
|
3
|
-
[English](diagnostics.md)
|
|
4
|
-
|
|
5
|
-
本插件在浏览器端输出的所有诊断信息都经由同一条分级通道,因此异常可在单个控制台过滤器下浮现。该通道**刻意收窄**:只记录插件自身检测到的、偏离预期契约的真实内部异常——**不记录**用户早已在屏幕上看到、且无归因价值的普通路径事实。
|
|
6
|
-
|
|
7
|
-
## 常开的异常告警
|
|
8
|
-
|
|
9
|
-
`error` 与 `warn` **总是打印**(无需开关、无需配置),即使从未配置的用户也能看到故障:
|
|
10
|
-
|
|
11
|
-
| 级别 | 含义 |
|
|
12
|
-
| --- | --- |
|
|
13
|
-
| `error` | 意外/破坏性故障(如设置卡片注册失败) |
|
|
14
|
-
| `warn` | 可恢复的异常护栏(如回退命令/回填抛出异常、等待的结果始终未落定、会话绑定缺失) |
|
|
15
|
-
|
|
16
|
-
这些情形少见、廉价、且偏离契约。普通路径的细节(回填走了哪个分支、写入是否匹配、回退切断哪些行)则**刻意不记录**:它们重复用户已经看到的现象,对归因毫无增量。
|
|
17
|
-
|
|
18
|
-
## 作用域
|
|
19
|
-
|
|
20
|
-
诊断以 `[dsh-rewind:<scope>] ...` 形式打印。`scope` 标明异常所属子系统,因此可用单个控制台过滤器抓取报告:
|
|
21
|
-
|
|
22
|
-
| scope | 这里的异常含义 |
|
|
23
|
-
| --- | --- |
|
|
24
|
-
| `boot` | 启动身份行——`loaded v<version> (build <hash>)`,由详细输出开关控制(见下)。用于确认运行中的 bundle 是否匹配某个修复。 |
|
|
25
|
-
| `refill` | 回退后的输入框回填(命令/等待/回填抛出异常、命令被拒绝或未匹配、等待的结果始终未落定) |
|
|
26
|
-
| `preview` | 模式气泡背后的 `/rewind preview` 影响探测(命令被拒绝/未匹配/抛出异常、等待的结果始终未落定) |
|
|
27
|
-
| `portals` | 每条消息按钮的挂载问题(如无会话绑定) |
|
|
28
|
-
| `retract` | 插话撤回路径(remove 竞争失败,或 remove 抛错) |
|
|
29
|
-
| `settings` | 快照清理设置卡片 |
|
|
30
|
-
|
|
31
|
-
## 详细输出开关
|
|
32
|
-
|
|
33
|
-
`info`/`debug` 级别受 `localStorage['dsh-rewind.debug']` 控制,每次调用读取并按命名空间过滤。仅剩两条受控行:`boot` 启动身份行与 `retract` 竞争失败行。两者**默认关闭**(普通用户控制台保持干净,且都不是异常),排查者需先开启对应作用域才能看到:
|
|
34
|
-
|
|
35
|
-
```js
|
|
36
|
-
// 仅启动身份行。
|
|
37
|
-
localStorage['dsh-rewind.debug'] = 'dsh-rewind:boot'
|
|
38
|
-
|
|
39
|
-
// 或全部开启(省去精确 scope)。
|
|
40
|
-
localStorage['dsh-rewind.debug'] = 'dsh-rewind*'
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
设置后刷新(`F5`)。关闭:
|
|
44
|
-
|
|
45
|
-
```js
|
|
46
|
-
delete localStorage['dsh-rewind.debug']
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
`error`/`warn` 异常告警**不受**该开关控制——它们总是打印。
|
|
50
|
-
|
|
51
|
-
## 采集一段报告
|
|
52
|
-
|
|
53
|
-
1. 在出问题的页面上复现一次。
|
|
54
|
-
2. 在 DevTools 里把 Console 按 `dsh-rewind` 过滤,复制输出(连同插件版本、DSH/内核版本)。
|
|
55
|
-
|
|
56
|
-
`error`/`warn` 异常告警无需任何设置——它们总是打印。若要同时采集启动身份行(`boot`,用于排除「旧 bundle / 宿主未重启」),请先开启详细输出开关(见上),再刷新。
|
|
57
|
-
|
|
58
|
-
## 备注
|
|
59
|
-
|
|
60
|
-
- 该诊断表面是维护者与配合排查者使用的工具,**不是**稳定公开接口;其具体键与输出可能不经通知而改变。
|
|
61
|
-
- 它受单个浏览器 origin、单个浏览器限定;请在实际出问题的地方采集。
|
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
# File-rewind tracking boundary
|
|
2
|
-
|
|
3
|
-
> When it rewinds files, the plugin **writes back only content it actually backed
|
|
4
|
-
> up**: a file changed by a write-class tool (`write`, `edit`) can always be restored
|
|
5
|
-
> exactly, while a stretch of state that **was not backed up** makes the restore
|
|
6
|
-
> **incomplete** — it prefers to do nothing rather than guess.
|
|
7
|
-
>
|
|
8
|
-
> This document describes how it works, the two known limitations that follow, and
|
|
9
|
-
> why they are accepted. All of it is a **deliberate trade-off** (matching Claude
|
|
10
|
-
> Code's checkpoint semantics), not a defect.
|
|
11
|
-
|
|
12
|
-
## 1. The two modes of file change
|
|
13
|
-
|
|
14
|
-
A file in DSH can change in exactly two ways, and the plugin observes them very
|
|
15
|
-
differently.
|
|
16
|
-
|
|
17
|
-
**The model calls a write-class tool** (`write`, `edit`): the call's arguments carry
|
|
18
|
-
the exact path, so the plugin can capture the file's current content **before** the
|
|
19
|
-
write lands. This path is deterministic — once handled, the file is certainly
|
|
20
|
-
restorable.
|
|
21
|
-
|
|
22
|
-
**An external edit** (a command-line Bash, a PowerShell write, a build script
|
|
23
|
-
rewriting files, or saving by hand in an editor): nothing declares the modified path
|
|
24
|
-
to the plugin, so **there is no object to capture in advance**; the plugin can only
|
|
25
|
-
discover it after the fact.
|
|
26
|
-
|
|
27
|
-
## 2. How it works
|
|
28
|
-
|
|
29
|
-
### One backup = the state when one message began
|
|
30
|
-
|
|
31
|
-
The plugin does not store the workspace's current state; it stores **state
|
|
32
|
-
transitions**. Every backup answers one question:
|
|
33
|
-
|
|
34
|
-
> **What was this file when that message began?**
|
|
35
|
-
|
|
36
|
-
Its content has two forms: `null` means the file **did not exist** (a restore then
|
|
37
|
-
deletes it), otherwise it is a full copy. Together these backups are the session's
|
|
38
|
-
**snapshot** (grouped by message). A backup belongs to **the user message that
|
|
39
|
-
triggered it**; within a single message's turn, every write-class tool call leaves a
|
|
40
|
-
backup of its own, and a restore takes the **earliest** of them.
|
|
41
|
-
|
|
42
|
-
### Where backups come from: the tracked list
|
|
43
|
-
|
|
44
|
-
The plugin does not scan the workspace; it follows one **tracked list**: a file joins
|
|
45
|
-
it as soon as a write-class tool handles it **once**; from then on, every new user
|
|
46
|
-
message makes the plugin re-check the files **on that list**; a file that never joins
|
|
47
|
-
it has no backup at all.
|
|
48
|
-
|
|
49
|
-
Backups come from two sources:
|
|
50
|
-
|
|
51
|
-
- **Before-write backup**: when the model calls a write-class tool, the plugin takes
|
|
52
|
-
the current content **before** the change lands and stores it under **the user
|
|
53
|
-
message that started this turn**;
|
|
54
|
-
- **Message-boundary re-check**: when a new message arrives, if a file on the list
|
|
55
|
-
now differs from its **latest backup**, the current content is recorded as **that
|
|
56
|
-
message's** backup. This is the only way an external edit gets backed up.
|
|
57
|
-
|
|
58
|
-
### Two cases where no backup is produced
|
|
59
|
-
|
|
60
|
-
- **Unchanged, not stored**: when the boundary re-check finds the disk identical to
|
|
61
|
-
the latest backup, this re-check writes nothing; if a write-class tool edits that
|
|
62
|
-
file later in the same turn, that before-write backup is still recorded. A message
|
|
63
|
-
that changed no file therefore produces no backup of its own.
|
|
64
|
-
- **Over the cap, not stored**: before writing a backup, if a single file is found to
|
|
65
|
-
exceed the size cap (8 MiB by default, adjustable via `DSH_REWIND_MAX_FILE_BYTES`,
|
|
66
|
-
`0` disables it), the plugin **refuses the backup**; until that file is back under
|
|
67
|
-
the cap, no new backup is written for it (the earlier backups remain).
|
|
68
|
-
|
|
69
|
-
### Restore: backups are the only standard
|
|
70
|
-
|
|
71
|
-
A restore reads nothing else in the workspace and infers nothing:
|
|
72
|
-
|
|
73
|
-
1. **Find the backup**: per file, take the **earliest** backup at or after the
|
|
74
|
-
**target message**; when the target message has no backup of its own, look toward
|
|
75
|
-
the later messages for the earliest one.
|
|
76
|
-
2. **Compare with the disk**: if it matches the current disk content, nothing is
|
|
77
|
-
done; otherwise the backup's content is written back (a `null` backup deletes the
|
|
78
|
-
file).
|
|
79
|
-
|
|
80
|
-
Looking toward later messages works because "unchanged, not stored" means the target
|
|
81
|
-
message began from the same state as its latest backup — so the earliest later backup
|
|
82
|
-
holds exactly that state (the premise being that nothing between the target message
|
|
83
|
-
and that backup went unrecorded; see limitation 2). Conversely, the nearest backup
|
|
84
|
-
*before* the target is not used because it may predate a **coverage gap** (limitation
|
|
85
|
-
2, case 3): writing it back would overwrite the current file with an older version.
|
|
86
|
-
**Restore less rather than write something wrong.**
|
|
87
|
-
|
|
88
|
-
### An example
|
|
89
|
-
|
|
90
|
-
| Msg | In the turn | File content | Backup: what it was when that message began |
|
|
91
|
-
| :---: | --- | :---: | --- |
|
|
92
|
-
| 1 | `write` creates it | `—` → `A` | `null` (the file did not exist before the write; backed up before the write and added to tracking) |
|
|
93
|
-
| 2 | — | `A` | `A` (the disk differs from the previous backup `null`) |
|
|
94
|
-
| 3 | — | `A` | (none: identical to the latest backup, and nothing was edited in this turn, unchanged, not stored) |
|
|
95
|
-
| 4 | `edit` | `A` → `B` | `A` (the content before that edit, a before-write backup) |
|
|
96
|
-
| 5 | `bash` rewrite | `B` → `C` | `B` (msg 4's edit had already taken effect, so the disk held `B` when msg 5 arrived) |
|
|
97
|
-
|
|
98
|
-
The disk now holds `C`. Rewinding gives: target 1 → the backup is `null`, the file is
|
|
99
|
-
deleted; target 2 → `A`; target 3 → no backup of its own, so looking toward later
|
|
100
|
-
messages finds msg 4's `A`, exactly the state msg 3 began from; target 4 → `A`;
|
|
101
|
-
target 5 → `B`.
|
|
102
|
-
|
|
103
|
-
## 3. Known limitations
|
|
104
|
-
|
|
105
|
-
Both limitations share one root cause: **a stretch of state was not backed up**.
|
|
106
|
-
Limitation 1 results in **no restore at all**, limitation 2 in an **incomplete
|
|
107
|
-
restore**.
|
|
108
|
-
|
|
109
|
-
### Limitation 1: history that never entered tracking — no restore
|
|
110
|
-
|
|
111
|
-
**Being handled by a write-class tool is the prerequisite for a file to join the
|
|
112
|
-
tracked list and produce backups.** Before that, every external edit it went through
|
|
113
|
-
has no backup, and neither source will write one for it. Two cases:
|
|
114
|
-
|
|
115
|
-
**Nothing in the session ever handled it.** Rewinding to any message can only report
|
|
116
|
-
"no restorable changes" for that file.
|
|
117
|
-
|
|
118
|
-
**Something handled it later (the more common case).** Backups exist only from that
|
|
119
|
-
moment on, so earlier history cannot be restored — in the example below, `v0` is
|
|
120
|
-
history the plugin has never seen:
|
|
121
|
-
|
|
122
|
-
| Msg | In the turn | File content | Backup: what it was when that message began |
|
|
123
|
-
| :---: | --- | :---: | --- |
|
|
124
|
-
| 1 | — | `v0` | (none: the file has not joined the tracked list) |
|
|
125
|
-
| 2 | a manual edit | `v0` → `v1` | (none: as above) |
|
|
126
|
-
| 3 | a manual edit | `v1` → `v2` | (none: as above) |
|
|
127
|
-
| 4 | `edit`, the first time it is handled | `v2` → `v3` | `v2` (before-write backup: the file joins the tracked list at this moment) |
|
|
128
|
-
| 5 | — | `v3` | `v3` (the disk differs from the previous backup `v2`) |
|
|
129
|
-
|
|
130
|
-
Rewinding to msg 5 → `v3`; to msg 4 → `v2`; and **rewinding to msg 1, 2 or 3 also
|
|
131
|
-
gives `v2`** — the earliest backup after them is msg 4's `v2`. `v2` really is the
|
|
132
|
-
content at the moment the file was first handled, but what those three messages began
|
|
133
|
-
from was `v0`, `v0` and `v1`; the plugin never saw them and cannot restore them.
|
|
134
|
-
|
|
135
|
-
### Limitation 2: a stretch of state that was not backed up — an incomplete restore
|
|
136
|
-
|
|
137
|
-
Both "unchanged, not stored" and "over the cap, not stored" leave a stretch of state
|
|
138
|
-
without a backup. When the target message has no backup of its own, the plugin looks
|
|
139
|
-
toward later messages for the backup; as soon as an **unrecorded change** happened
|
|
140
|
-
between the target and that backup, what it takes is no longer the state the target
|
|
141
|
-
message began from. The three cases below stack one more scenario each.
|
|
142
|
-
|
|
143
|
-
**Case 1: the change does not belong to any message yet**
|
|
144
|
-
|
|
145
|
-
| Msg | In the turn | File content | Backup |
|
|
146
|
-
| :---: | --- | :---: | --- |
|
|
147
|
-
| 1 | `write` creates it | `—` → `A` | `null` |
|
|
148
|
-
| 2 | — | `A` | `A` (differs from the previous backup `null`) |
|
|
149
|
-
| 3 | — | `A` | (none: identical to the previous backup, and nothing was edited in this turn) |
|
|
150
|
-
| (after) | a manual edit | `A` → `X` | (none: no new message has claimed it yet) |
|
|
151
|
-
|
|
152
|
-
Rewinding to msg 3: neither it nor anything later has a backup, the plugin does not
|
|
153
|
-
know the file changed, so it **does nothing and the file stays at `X`** (msg 3
|
|
154
|
-
actually began with `A`). To recover `A`, rewind to msg 2 — the backup under it is
|
|
155
|
-
`A`. A later message only makes the state **at that time** (`X`) rewindable; the `A`
|
|
156
|
-
msg 3 began from still cannot come back.
|
|
157
|
-
|
|
158
|
-
**Case 2: what is backed up is the state after the change**
|
|
159
|
-
|
|
160
|
-
| Msg | In the turn | File content | Backup |
|
|
161
|
-
| :---: | --- | :---: | --- |
|
|
162
|
-
| 1 | `write` creates it | `—` → `A` | `null` |
|
|
163
|
-
| 2 | — | `A` | `A` (differs from the previous backup `null`) |
|
|
164
|
-
| 3 | — | `A` | (none: identical to the latest backup) |
|
|
165
|
-
| 4 | a command changes it to `B` | `A` → `B` | (none: the disk was still `A` when msg 4 arrived) |
|
|
166
|
-
| 5 | — | `B` | `B` (differs from the previous backup `A`) |
|
|
167
|
-
|
|
168
|
-
Rewinding to msg 3 or 4: neither has a backup of its own, so looking toward later
|
|
169
|
-
messages finds msg 5's `B` — the state **after** the change, while they began with
|
|
170
|
-
`A`. The disk matches that backup, so the plugin does nothing and the file stays at
|
|
171
|
-
`B`; if the file changed again afterwards, the plugin writes `B` (overwriting
|
|
172
|
-
whatever is newer); and if the change in msg 4's turn had been a command **deleting**
|
|
173
|
-
the file, msg 5's backup would be `null` and rewinding to 3 or 4 would **delete the
|
|
174
|
-
current file**. The difference from case 1 is that a message followed this change and
|
|
175
|
-
recorded the state after it — in case 1 nothing was recorded at all.
|
|
176
|
-
|
|
177
|
-
**Case 3: the file exceeded the cap while it changed**
|
|
178
|
-
|
|
179
|
-
| Msg | In the turn | File content | Backup |
|
|
180
|
-
| :---: | --- | :---: | --- |
|
|
181
|
-
| 1 | `write` creates it | `—` → `A` | `null` |
|
|
182
|
-
| 2 | the edit grows it past the cap | `A` → `G1` | `A` (differs from the previous backup `null`) |
|
|
183
|
-
| 3 | a write-class tool or an external edit | `G1` → `G2` | (none: over the cap, not backed up) |
|
|
184
|
-
| 4 | shrinks back to `B` | `G2` → `B` | (none: still over the cap on arrival, so the re-check skipped it) |
|
|
185
|
-
| 5 | `edit` | `B` → `X` | `B` (a before-write backup; tracking resumes) |
|
|
186
|
-
|
|
187
|
-
In the "small → big → small" scenario the stretch over the cap has no backup at all —
|
|
188
|
-
a **coverage gap**. Rewinding to msg 3 or 4 finds msg 5's `B`, so the plugin writes
|
|
189
|
-
`B`; the "big" state they began from falls inside the gap and can never be restored.
|
|
190
|
-
And if the file is currently over the cap, **no** target touches it: it prefers to do
|
|
191
|
-
nothing rather than write back an older backup.
|
|
192
|
-
|
|
193
|
-
## 4. Why these limitations are accepted
|
|
194
|
-
|
|
195
|
-
Removing **limitation 1** would require the plugin to know which files an arbitrary
|
|
196
|
-
command or external edit modified. Neither route works: having every external writer
|
|
197
|
-
declare itself (neither `sed`, nor build scripts, nor editors will), or tracking the
|
|
198
|
-
whole workspace and taking a whole-tree snapshot before every change — enormous in
|
|
199
|
-
volume, and precise workspace-level history is exactly the job of dedicated snapshot
|
|
200
|
-
tools such as Git. The plugin only does lightweight rewinding aimed at Agent tooling,
|
|
201
|
-
so this limitation stays.
|
|
202
|
-
|
|
203
|
-
Removing **limitation 2** would mean giving up "unchanged, not stored" and "over the
|
|
204
|
-
cap, not stored": every message boundary would have to record **every** tracked file
|
|
205
|
-
(changed or not), and large files would be backed up in full again and again. That
|
|
206
|
-
cost has two layers: storage grows linearly with the conversation, and messages that
|
|
207
|
-
change no file are the overwhelming majority; and every backup reads and writes a
|
|
208
|
-
whole file, so a very large one saturates disk IO (which is exactly why the per-file
|
|
209
|
-
cap exists).
|
|
210
|
-
|
|
211
|
-
What the two limitations buy is: lightweight, predictable, and **only ever writing
|
|
212
|
-
back content it actually backed up, never a guess**. For precise workspace-level
|
|
213
|
-
history, use Git.
|
|
214
|
-
|
|
215
|
-
## See also
|
|
216
|
-
|
|
217
|
-
[SECURITY.md](../../SECURITY.md) · [Snapshot auto-cleanup](../snapshot-auto-cleanup.md)
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
# 文件回退的追踪边界
|
|
2
|
-
|
|
3
|
-
> 回退文件时,插件**只写回它确实备份过的内容**:被写类工具改动过的文件始终可以精确还原;而某一段状态**没有被备份**时,还原就会**不充分**——它宁可不动,也不猜测。
|
|
4
|
-
>
|
|
5
|
-
> 本文说明它的原理、由此产生的两项已知限制,以及为什么接受它们。这些都是**有意取舍**(与 Claude Code 的检查点语义一致),不是缺陷。
|
|
6
|
-
|
|
7
|
-
## 一、文件变更的两种模式
|
|
8
|
-
|
|
9
|
-
文件在 DSH 中只有两种变化途径,插件对二者的可观测性完全不同。
|
|
10
|
-
|
|
11
|
-
**模型调用写类工具**(`write`、`edit`):调用参数给出了确切的文件路径,插件能在文件被覆盖**之前**捕获它的当前内容。这条路径是确定性的——一经处理,这个文件就一定可以还原。
|
|
12
|
-
|
|
13
|
-
**外部编辑**(命令行 Bash、PowerShell 写入、构建脚本改写,或手动在编辑器里保存):没有任何环节向插件声明被修改的路径,**不存在可以提前捕获的对象**,只能事后发现。
|
|
14
|
-
|
|
15
|
-
## 二、原理
|
|
16
|
-
|
|
17
|
-
### 一份备份 = 一条消息开始时的状态
|
|
18
|
-
|
|
19
|
-
插件不保存"工作区当前状态",保存的是**状态转变**。每份备份只回答一个问题:
|
|
20
|
-
|
|
21
|
-
> **这条消息开始时,这个文件是什么?**
|
|
22
|
-
|
|
23
|
-
内容有两种形态:`null` 表示文件**不存在**(还原时表现为删除),另一种则是完整副本。这些备份合起来就是这个会话的**快照**(按消息分组存放)。备份归属于**触发它的那条用户消息**;同一条消息的回合里,每次写类工具调用都会留下一份备份,还原时取其中**最早**的一份。
|
|
24
|
-
|
|
25
|
-
### 备份从哪里来:追踪列表
|
|
26
|
-
|
|
27
|
-
插件不扫描整个工作区,只认一张**追踪列表**:文件被写类工具处理**一次**即加入;此后每条新的用户消息到达时,只复查名单里的文件;不在名单里的文件没有任何备份。
|
|
28
|
-
|
|
29
|
-
备份有两个来源:
|
|
30
|
-
|
|
31
|
-
- **写前备份**:模型调用写类工具时,在改动落盘**之前**取当前内容,记在**触发这一轮的那条用户消息**名下;
|
|
32
|
-
- **消息边界复查**:新消息到达时,若名单里某个文件当前的磁盘内容与**最近一份备份**不一致,就把当前内容记为**这条消息**的备份。这是外部编辑唯一的入口。
|
|
33
|
-
|
|
34
|
-
### 两种不产生备份的情形
|
|
35
|
-
|
|
36
|
-
- **不变不存**:边界复查发现磁盘与最近一份备份一致时,这次复查不写备份;若本回合稍后写类工具改动了该文件,那份写前备份照常记录。所以"没有改动文件的消息"自己不产生备份。
|
|
37
|
-
- **超量不存**:准备写备份之前若发现单个文件超过体积上限(默认 8 MiB,可用 `DSH_REWIND_MAX_FILE_BYTES` 调整,设为 `0` 关闭),插件**拒绝备份**;这个文件在回到限制以下之前不会产生新的备份(旧备份仍在)。
|
|
38
|
-
|
|
39
|
-
### 还原:只依据备份
|
|
40
|
-
|
|
41
|
-
还原不读工作区的其他内容,也不做推断:
|
|
42
|
-
|
|
43
|
-
1. **找备份**:对每个文件,取**目标消息及其之后最早**的一份备份;目标消息自己没有备份时,就向更晚的消息找最早的那一份。
|
|
44
|
-
2. **与磁盘比对**:一致 → 不做任何操作;不一致 → 写回备份的内容(`null` → 删除该文件)。
|
|
45
|
-
|
|
46
|
-
向更晚的消息找,是因为"不变不存"意味着目标消息开始时的状态与最近一份备份相同——更晚消息的最早备份,记的正是那一刻的状态(前提是目标消息之后、那份备份之前没有未被记录的改动,见限制二)。反过来,不取"目标消息之前最近"的那份备份,是因为它可能早于一个**缺口**(限制二·表现 3):写回它等于用更旧的版本覆盖当前文件。**宁可少还原,也不写错内容。**
|
|
47
|
-
|
|
48
|
-
### 示例
|
|
49
|
-
|
|
50
|
-
| 消息 | 回合内发生 | 文件内容 | 备份:这条消息开始时是什么 |
|
|
51
|
-
| :---: | --- | :---: | --- |
|
|
52
|
-
| 1 | `write` 新建 | `—` → `A` | `null`(落盘前文件不存在,写前备份并加入追踪) |
|
|
53
|
-
| 2 | — | `A` | `A`(磁盘与上一份备份 `null` 不一致) |
|
|
54
|
-
| 3 | — | `A` | (无:与最近一份备份一致,且本轮未编辑,不变不存) |
|
|
55
|
-
| 4 | `edit` | `A` → `B` | `A`(本次编辑之前的内容,写前备份) |
|
|
56
|
-
| 5 | `bash` 改写 | `B` → `C` | `B`(消息 5 到达时磁盘已是 `B`,消息 4 的编辑已生效) |
|
|
57
|
-
|
|
58
|
-
磁盘当前是 `C`。回退结果:目标 1 → 备份为 `null`,删除文件;目标 2 → `A`;目标 3 → 自己没有备份,向更晚的消息找到消息 4 的 `A`,正是消息 3 开始时的状态;目标 4 → `A`;目标 5 → `B`。
|
|
59
|
-
|
|
60
|
-
## 三、已知限制
|
|
61
|
-
|
|
62
|
-
两项限制的根因相同:**某一段状态没有被备份**。限制一的结果是**无法还原**,限制二的结果是**还原不充分**。
|
|
63
|
-
|
|
64
|
-
### 限制一:没有进入追踪的历史 —— 无法还原
|
|
65
|
-
|
|
66
|
-
**"被写类工具处理过"是文件加入追踪列表、产生备份的前提。** 在此之前,它经历的所有外部编辑都没有备份,两个来源也都不会为它写备份。两种情形:
|
|
67
|
-
|
|
68
|
-
**整个会话都没有处理过它。** 回退到任何消息,插件对该文件都只能报告"没有可还原的改动"。
|
|
69
|
-
|
|
70
|
-
**后来处理过它(更常见)。** 从那一刻起才有备份,更早的历史无法还原——下面例子里 `v0` 是插件从未见过的历史:
|
|
71
|
-
|
|
72
|
-
| 消息 | 回合内发生 | 文件内容 | 备份:这条消息开始时是什么 |
|
|
73
|
-
| :---: | --- | :---: | --- |
|
|
74
|
-
| 1 | — | `v0` | (无:文件还没有加入追踪列表) |
|
|
75
|
-
| 2 | 手动编辑 | `v0` → `v1` | (无:同上) |
|
|
76
|
-
| 3 | 手动编辑 | `v1` → `v2` | (无:同上) |
|
|
77
|
-
| 4 | `edit` 首次处理 | `v2` → `v3` | `v2`(写前备份:这一刻文件才加入追踪列表) |
|
|
78
|
-
| 5 | — | `v3` | `v3`(磁盘与上一份备份 `v2` 不一致) |
|
|
79
|
-
|
|
80
|
-
回退到消息 5 → `v3`;消息 4 → `v2`;而**回退到消息 1、2、3 也都会得到 `v2`**——它们之后最早的备份就是消息 4 的 `v2`。`v2` 确实是"首次处理那一刻"的内容,但这三条消息开始时的真实内容分别是 `v0`、`v0`、`v1`,插件从未见过,无法还原。
|
|
81
|
-
|
|
82
|
-
### 限制二:有一段状态没有被备份 —— 还原不充分
|
|
83
|
-
|
|
84
|
-
"不变不存"与"超量不存"都会让某一段状态没有备份。目标消息自己没有备份时,插件按"向更晚的消息找"取备份;一旦目标之后、那份备份之前发生过**未被记录的改动**,取到的就不是目标消息开始时的状态。三种表现依次叠加更多场景。
|
|
85
|
-
|
|
86
|
-
**表现 1:变更还没有归属到任何消息**
|
|
87
|
-
|
|
88
|
-
| 消息 | 回合内发生 | 文件内容 | 备份 |
|
|
89
|
-
| :---: | --- | :---: | --- |
|
|
90
|
-
| 1 | `write` 新建 | `—` → `A` | `null` |
|
|
91
|
-
| 2 | — | `A` | `A`(与上一份备份 `null` 不一致) |
|
|
92
|
-
| 3 | — | `A` | (无:与上一份备份一致,且本轮未编辑) |
|
|
93
|
-
| (之后) | 手动修改文件 | `A` → `X` | (无:还没有新消息来认领它) |
|
|
94
|
-
|
|
95
|
-
回退到消息 3:它自己及其之后都没有备份,插件不知道文件变过,于是**不做任何操作,文件停在 `X`**(消息 3 开始时其实是 `A`)。要取回 `A`,回退到消息 2——它名下的备份就是 `A`。之后再来的消息只会让**当时**的状态(`X`)变得可回退,消息 3 开始时的 `A` 仍然回不来。
|
|
96
|
-
|
|
97
|
-
**表现 2:记下的是变更之后的状态**
|
|
98
|
-
|
|
99
|
-
| 消息 | 回合内发生 | 文件内容 | 备份 |
|
|
100
|
-
| :---: | --- | :---: | --- |
|
|
101
|
-
| 1 | `write` 新建 | `—` → `A` | `null` |
|
|
102
|
-
| 2 | — | `A` | `A`(与上一份备份 `null` 不一致) |
|
|
103
|
-
| 3 | — | `A` | (无:与最近一份备份一致) |
|
|
104
|
-
| 4 | 命令把文件改成 `B` | `A` → `B` | (无:消息 4 到达时磁盘还是 `A`) |
|
|
105
|
-
| 5 | — | `B` | `B`(与上一份备份 `A` 不一致) |
|
|
106
|
-
|
|
107
|
-
回退到消息 3 或 4:它们自己没有备份,向更晚的消息找到消息 5 的 `B`——那是变更**之后**的状态,而它们开始时是 `A`。此时磁盘与备份一致,插件不做任何操作,文件停在 `B`;若此后文件又变过,插件会把文件写成 `B`(覆盖比它更新的内容);若消息 4 的回合里是命令**删除**文件,消息 5 的备份就是 `null`,回退到 3 或 4 会**删除当前文件**。与表现 1 的差别在于:这次变更之后还有消息,它把变更后的状态记了下来——表现 1 里则一条都没记。
|
|
108
|
-
|
|
109
|
-
**表现 3:变更期间文件超过上限**
|
|
110
|
-
|
|
111
|
-
| 消息 | 回合内发生 | 文件内容 | 备份 |
|
|
112
|
-
| :---: | --- | :---: | --- |
|
|
113
|
-
| 1 | `write` 新建 | `—` → `A` | `null` |
|
|
114
|
-
| 2 | 文件扩充 | `A` → `G1` | `A`(与上一份备份 `null` 不一致) |
|
|
115
|
-
| 3 | 写类工具或外部修改 | `G1` → `G2` | (无:超过上限,不备份) |
|
|
116
|
-
| 4 | 缩回 `B` | `G2` → `B` | (无:消息到达时仍超限,跳过复查) |
|
|
117
|
-
| 5 | `edit` | `B` → `X` | `B`(写前备份,重新追踪) |
|
|
118
|
-
|
|
119
|
-
"小 → 大 → 小"的场景中,超过上限的那一段没有任何备份,是一个**缺口**。回退到消息 3、4 时取到的是消息 5 的 `B`——插件会把文件写成 `B`;它们开始时的"大"状态落在缺口里,永远无法还原。若磁盘当前仍然超过上限,**任何**目标都不会动这个文件:宁可不动,也不写回一份更旧的备份。
|
|
120
|
-
|
|
121
|
-
## 四、为什么接受这些限制
|
|
122
|
-
|
|
123
|
-
**限制一**要消除,得让插件知道任意命令、外部编辑动了哪些文件。两条路都不通:一是让所有外部写入主动申报——`sed`、构建脚本、编辑器都不会这么做;二是全量追踪整个工作区、每次改动前做整树快照——体量极大,而工作区级的精确历史正是 Git 这类专业快照工具的职责。插件只做面向 Agent 工具的轻量回退,因此保留这条限制。
|
|
124
|
-
|
|
125
|
-
**限制二**要消除,必须放弃"不变不存"与"超量不存":每个消息边界都要记录**每个**被追踪文件(即使一点没变),大文件也会被反复完整备份。代价有两层——存储量随对话长度线性增长,而未改动文件的消息占绝大多数;以及每次备份都要完整读写文件,文件很大时会把磁盘 IO 打满(单文件上限正是为此而设)。
|
|
126
|
-
|
|
127
|
-
两项限制换来的是:轻量、可预测,以及**只写它确实备份过的内容、绝不做任何猜测**。需要工作区级别的精确历史时,请使用 Git。
|
|
128
|
-
|
|
129
|
-
## 相关
|
|
130
|
-
|
|
131
|
-
[SECURITY.md](../../SECURITY.md) · [快照自动清理](../snapshot-auto-cleanup.zh.md)
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
# Client contract — rewind visibility
|
|
2
|
-
|
|
3
|
-
How third-party DOM plugins learn **which transcript rows a rewind withdrew**.
|
|
4
|
-
This is the only sanctioned answer to that question; anything not listed here
|
|
5
|
-
is internal and may change without notice.
|
|
6
|
-
|
|
7
|
-
## Stability tiers
|
|
8
|
-
|
|
9
|
-
| Channel | Stability | Consumers |
|
|
10
|
-
| --- | --- | --- |
|
|
11
|
-
| `/rewind` command `args` `@<seq>` | ✅ stable, semver-protected | machine |
|
|
12
|
-
| `outcome.sourceEventSeq` (marker log seq) | ✅ stable, semver-protected | machine |
|
|
13
|
-
| `data-dsh-rewind-hidden` attribute | ✅ stable name; observational only | DOM plugins |
|
|
14
|
-
| `outcome.text` | ❌ **not** stable — human copy, never parse | — |
|
|
15
|
-
|
|
16
|
-
## The sanctioned machine channel
|
|
17
|
-
|
|
18
|
-
`dsh-rewind-plugin/client` exports the pure, locale-independent computation
|
|
19
|
-
the plugin itself uses (it never reads the DOM or host copy):
|
|
20
|
-
|
|
21
|
-
```ts
|
|
22
|
-
import { hiddenSeqsOf, type HiddenChat } from 'dsh-rewind-plugin/client'
|
|
23
|
-
|
|
24
|
-
const chat = uiConversation.binding(sessionId).target('chat')?.getSnapshot() // the `uiConversation` "chat" view
|
|
25
|
-
const hidden = hiddenSeqsOf(chat as HiddenChat) // Set<number> of anchor seqs
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
`hiddenSeqsOf` hides every internal probe row (`preview` / `__candidates`),
|
|
29
|
-
every successful executed `/rewind` row, and every message inside its
|
|
30
|
-
`[target, marker]` span (each rewind cuts one span; spans stay separate).
|
|
31
|
-
`targetSeqOfArgs` is exported for consumers that only need the target from a
|
|
32
|
-
command's `args`. Reuse these instead of re-deriving the logic (cf.
|
|
33
|
-
dsh-chat-timeline#6).
|
|
34
|
-
|
|
35
|
-
## DOM attribute
|
|
36
|
-
|
|
37
|
-
Each withdrawn row carries `data-dsh-rewind-hidden="true"` while hidden,
|
|
38
|
-
removed on un-hide. The same attribute also lands on a step/process GROUP shell
|
|
39
|
-
— the view container around a turn's process rows, which is not itself a message
|
|
40
|
-
— when every member it holds is withdrawn. Contract:
|
|
41
|
-
|
|
42
|
-
- The **attribute name** is stable; treat the value as opaque.
|
|
43
|
-
- It is **observational only** — rewind hides via `style.display`; the
|
|
44
|
-
attribute records the cause, not the mechanism. Do not write it, and do
|
|
45
|
-
not expect it on rows outside rewind's control (third-party own elements).
|
|
46
|
-
|
|
47
|
-
## Explicit non-contract
|
|
48
|
-
|
|
49
|
-
`outcome.text` is human-facing copy. Its wording changes freely with
|
|
50
|
-
localization; parsing it is a bug. The `impact=<n>` trailer on preview text
|
|
51
|
-
is machine-readable and stable but outside this contract's scope.
|
|
52
|
-
|
|
53
|
-
## Maintenance rules
|
|
54
|
-
|
|
55
|
-
- Any change to the meaning of `@<seq>` / `sourceEventSeq` /
|
|
56
|
-
`data-dsh-rewind-hidden` updates this document in the same PR.
|
|
57
|
-
- Breaking a listed stability tier is a minor/major version bump.
|
|
58
|
-
- `scripts/build.mjs` asserts the export surface on every build.
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
# 客户端契约 — 回退可见性
|
|
2
|
-
|
|
3
|
-
第三方 DOM 插件如何获知**哪些转录行被回退撤回**。这是该问题的唯一权威
|
|
4
|
-
答案;未在此列出的内容均属内部实现,可随时变更,恕不另行通知。
|
|
5
|
-
|
|
6
|
-
## 稳定性分级
|
|
7
|
-
|
|
8
|
-
| 通道 | 稳定性 | 适用方 |
|
|
9
|
-
| --- | --- | --- |
|
|
10
|
-
| `/rewind` 命令 `args` 中的 `@<seq>` | ✅ 稳定,受 semver 保护 | 机器 |
|
|
11
|
-
| `outcome.sourceEventSeq`(marker 日志 seq) | ✅ 稳定,受 semver 保护 | 机器 |
|
|
12
|
-
| `data-dsh-rewind-hidden` 属性 | ✅ 属性名稳定;仅观测性 | DOM 插件 |
|
|
13
|
-
| `outcome.text` | ❌ **不**稳定 — 人类可读文案,禁止解析 | — |
|
|
14
|
-
|
|
15
|
-
## 唯一机器通道
|
|
16
|
-
|
|
17
|
-
`dsh-rewind-plugin/client` 导出插件自身所用的纯函数计算,与本地化无关
|
|
18
|
-
(不读取 DOM,也不解析 host 文案):
|
|
19
|
-
|
|
20
|
-
```ts
|
|
21
|
-
import { hiddenSeqsOf, type HiddenChat } from 'dsh-rewind-plugin/client'
|
|
22
|
-
|
|
23
|
-
const chat = uiConversation.binding(sessionId).target('chat')?.getSnapshot() // `uiConversation` 的 "chat" 视图
|
|
24
|
-
const hidden = hiddenSeqsOf(chat as HiddenChat) // 被隐藏的 anchor seq 集合
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
`hiddenSeqsOf` 隐藏所有内部探针行(`preview` / `__candidates`)、所有成功
|
|
28
|
-
执行的 `/rewind` 行,以及每条回退 `[target, marker]` 区间内的消息(每次
|
|
29
|
-
回退切一条区间,区间互不合并)。只需从命令 `args` 取 target 时,可用同样
|
|
30
|
-
导出的 `targetSeqOfArgs`。请复用这些实现,勿自行重写(参见
|
|
31
|
-
dsh-chat-timeline#6)。
|
|
32
|
-
|
|
33
|
-
## DOM 属性
|
|
34
|
-
|
|
35
|
-
每条被撤回的行在隐藏期间带有 `data-dsh-rewind-hidden="true"`,取消隐藏时
|
|
36
|
-
移除。契约如下:
|
|
37
|
-
|
|
38
|
-
- **属性名**稳定;值视为不透明。
|
|
39
|
-
- 仅**观测性**标记 — rewind 通过 `style.display` 隐藏,该属性记录的是
|
|
40
|
-
原因而非机制。不要写它;也不要在 rewind 控制范围之外(第三方自建元素)
|
|
41
|
-
期望它存在。
|
|
42
|
-
|
|
43
|
-
## 明确非契约
|
|
44
|
-
|
|
45
|
-
`outcome.text` 是面向人类用户的文案,措辞随本地化自由变更,解析它即视为
|
|
46
|
-
bug。preview 文案中的 `impact=<n>` 尾注是机器可读且稳定的,但不属于本
|
|
47
|
-
契约范围。
|
|
48
|
-
|
|
49
|
-
## 维护规则
|
|
50
|
-
|
|
51
|
-
- 任何改变 `@<seq>` / `sourceEventSeq` / `data-dsh-rewind-hidden` 语义的
|
|
52
|
-
改动,必须在同一 PR 中更新本文档。
|
|
53
|
-
- 破坏已承诺的稳定性分级属于 minor/major 版本变更。
|
|
54
|
-
- `scripts/build.mjs` 在每次构建时断言导出面。
|