dsh-rewind-plugin 0.14.0-beta.2 → 0.14.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 CHANGED
@@ -37,7 +37,7 @@ Check your local DSH version, then find the matching plugin version in
37
37
  **Command line**:
38
38
 
39
39
  ```sh
40
- dsh plugin --profile web add dsh-rewind-plugin@<version>
40
+ dsh plugin --profile <name> add dsh-rewind-plugin@<version>
41
41
  ```
42
42
 
43
43
  **Graphical interface**: the **Plugins** page in the sidebar → **Add plugin** → enter `dsh-rewind-plugin@<version>` → install → restart DSH and refresh the browser.
@@ -91,7 +91,7 @@ auto-cleanup switch and the idle-day cutoff), or use the
91
91
 
92
92
  ```sh
93
93
  # Uninstall the plugin
94
- dsh plugin --profile web remove dsh-rewind-plugin
94
+ dsh plugin --profile <name> remove dsh-rewind-plugin
95
95
 
96
96
  # To also delete local data
97
97
  rm -rf <dsh home>/rewind-snapshots
@@ -131,10 +131,10 @@ The whole design rests on two principles, simple but deliberate: **the conversat
131
131
 
132
132
  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:
133
133
 
134
- - **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.
134
+ - **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**.
135
135
  - **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".
136
136
  - **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.
137
- - **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".
137
+ - **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". There is a limit, though: [File-rewind tracking boundary](docs/compat/tracking-boundary.md).
138
138
  - **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.
139
139
 
140
140
  > **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.
package/README.md CHANGED
@@ -36,7 +36,7 @@ DeepSeek Harness 插件:**一键就地回退对话到任意更早的用户消
36
36
  **命令行**:
37
37
 
38
38
  ```sh
39
- dsh plugin --profile web add dsh-rewind-plugin@<版本>
39
+ dsh plugin --profile <name> add dsh-rewind-plugin@<版本>
40
40
  ```
41
41
 
42
42
  **图形界面**:侧栏「插件」页 →「添加插件」→ 填 `dsh-rewind-plugin@<版本>` → 安装 → 重启 DSH 并刷新浏览器。
@@ -79,7 +79,7 @@ dsh plugin --profile web add dsh-rewind-plugin@<版本>
79
79
 
80
80
  ```sh
81
81
  # 卸载插件
82
- dsh plugin --profile web remove dsh-rewind-plugin
82
+ dsh plugin --profile <name> remove dsh-rewind-plugin
83
83
 
84
84
  # 如需同时删除本地数据
85
85
  rm -rf <dsh home>/rewind-snapshots
@@ -118,10 +118,10 @@ rm <dsh home>/snapshot-cleanup-last-sweep.json
118
118
 
119
119
  文件部分对齐 Claude Code 的检查点语义——**局部追踪、写前备份 + 每条消息重扫已跟踪文件**,而不是整树快照。这项取舍既省空间,又更完整:
120
120
 
121
- - **写前备份**:只追踪写类工具(`write`、`edit`),写前**备份原内容**,并**记录、追踪**被处理的文件——从不备份整个工作区,因此轻量。
121
+ - **写前备份**:只追踪写类工具(`write`、`edit`),写前**备份原内容**,并**记录、追踪**被处理的文件——从不备份整个工作区,因此轻量;**单个文件过大时不备份**。
122
122
  - **外部变更也追**:每条用户消息边界,插件重新检查所有已跟踪文件——命令执行、手动修改等外部变更同样被记录,回退时一并还原。这让「轻量」却不「残缺」。
123
123
  - **不变不存**:记录只在有变化时发生——消息边界重扫时无变更的**不备份**(不留记录);写前备份时若与前一条记录一致,只存指向它的链接(`ref`)而非复制内容。
124
- - **还原准确**:以备份为唯一标准,对照真实磁盘,只动真正不一致的文件——被改过的还原、被新建的删除、被删除的恢复;备份逐字节存储。还原结果与备份一致,无“幽灵影响”。
124
+ - **还原准确**:以备份为唯一标准,对照真实磁盘,只动真正不一致的文件;备份逐字节存储。还原结果与备份一致,无“幽灵影响”。但存在限制:[文件回退的追踪边界](docs/compat/tracking-boundary.zh.md)。
125
125
  - **安全与完整性**:路径经安全化处理,绝不越出备份根目录;符号/硬链接跳过,避免透过一次还原误伤同名的另一份文件;单个文件失败绝不中止整轮还原;备份与还原日志均原子写落盘(跨重启仍在),断电或崩溃后的半还原可续做或回滚。
126
126
 
127
127
  > **设计点睛**:这套检查点的「轻」,来自**只记录被工具动过、且确实变化的文件**——写前备份保证可还原,不变不存与存链接压掉重复;还原时再对照真实磁盘,只动不一致的文件。
package/SECURITY.md CHANGED
@@ -175,7 +175,7 @@ transcript.
175
175
  distinct cases follow:
176
176
  1. **Sensitive / personal-information files** (e.g. a `.env` file, which may
177
177
  sit inside the workspace) the model may read or edit are backed up and
178
- restorable.
178
+ restorable, unless the file is over the size cap.
179
179
  2. **Files outside the session workspace** the model is allowed to touch are
180
180
  likewise backed up and restored.
181
181
  In both cases the plugin introduces no authority of its own — DSH already
@@ -58,7 +58,7 @@ legacy branch).
58
58
  - **agent-loop cancellation**: `finally` guarantees step/turn closure; the rewind force-stop path leaves no dangling frame.
59
59
  - **settings card (`0.1.7-alpha.1`)**: the card declares `configForms` as a module-level inject (the web profile always composes `ui-settings`), stages through the harness's own `SettingsFormModel` + `SettingsForm`, and registers into `plugins.bundle.config` keyed by the bundle package name; it reads that shared per-entry form's snapshot and writes through its revision-fenced `mutate`, disposing the model's subscription with the fiber. The host half declares the bundle's page policy (`configure({ auto: false }, ctx.fiber)` inside a `ctx.inject(['settings'])` child, per the dsh-settings README), opting the entry out of a client-built schema page. Pins: `verify:host` (`the bundle declares its own config page`), `tests/client-settings-card.test.ts` (staged plan, refusal, subscription release), `tests/client-lifecycle.test.ts`.
60
60
  - **client session ownership (`0.1.6-alpha.2`)**: `SessionListState.current` / `currentAddress` are gone; main-view ownership is read from the row's `retainedBy.mainView` label — what `ui-workspace` retains the open session with. Pin: `tests/client-refill.test.ts`.
61
- - **client pending input (`0.1.6-alpha.2`)**: `SessionSnapshot.queue` and the host `queue-mirror` are gone; pending steering rows come from the session's own `inbox` projection, and only USER-sourced `next-step` rows are retractable (the removed mapping was `source.kind === 'user' ? 'steering' : 'context'`). Pins: `tests/pending.test.ts`, `tests/client-retract.test.ts`.
61
+ - **client pending input (`0.1.6-alpha.2`)**: `SessionSnapshot.queue` and the host `queue-mirror` are gone; pending steering rows come from the session's own `inbox` projection, and only USER-sourced `next-step` rows are retractable (the removed mapping was `source.kind === 'user' ? 'steering' : 'context'`). The rendered bubble also stays the client's own submission echo until the durable node arrives (`ChatView.pendingInputs` merges the Host occurrence into that echo by `rpcId`; `QueueDock` mirrors only `next-turn`), so the occurrence lands with NO DOM mutation — the portal refresh therefore follows the `inbox` face's own `subscribe` beside the DOM observer. Pins: `tests/pending.test.ts`, `tests/client-retract.test.ts`, `tests/client-portal-refresh.test.ts`.
62
62
  - **client settings slot (`0.1.6-alpha.2`)**: `settings.plugin.item` is gone; the configuration form registers into `plugins.bundle.config`, keyed by the bundle package name. Pin: `tests/client-contract.test.ts`.
63
63
  - **`agent/created` lifecycle guard**: the session-format reconcile runs on the alpha line's `agent/created` (fire-and-forget); pin: `verify-host` 4e dispatches it.
64
64
  - **marker vs `/compact`**: the checkpoint shape is unchanged on this line — a `user/message` replace (`surfaceOp {replace, startSeq, endSeq}` + `sourceEventSeqs`); `assistant/message` still cannot carry `sourceEventSeqs`.
@@ -79,6 +79,7 @@ legacy branch).
79
79
  - **Rewind leaves plan mode untouched**: `/plan text` is two independent actions (enter plan mode + steer the message). Rewinding the message undoes only the message — the log-only `plan/mode` state stays active, and the user leaves plan mode with `/plan off`, which still commits after a rewind (the marker creates no open turn). Pin: `verify-host` plan checks (`plan rewind leaves plan mode active`, `/plan off after rewind turns plan mode off`), `tests/hidden.test.ts` `messageTextAt`.
80
80
  - **Bundle card metadata is declared by the plugin, not by the manager (`0.1.7-alpha.1`)**: the manager still titles a bundle with `shortName(pkg.name)` and its raw `package.json` description when nothing else is declared, but it now reads plugin-owned resources first — `locale/<lang>.json` (`meta.title`/`meta.description`) through the package exports map, and an `icon` file relative to the manifest. This plugin declares both, so its card shows an icon and copy that follows the active language; only the fallback description remains mixed-language. Pin: `tests/package-layout.test.ts` (icon path/media/size and both dictionaries).
81
81
  - **Synchronous Session history reads are deprecated** on this line (`snapshotEvents` / `eventAt` / `ownEvents`): existing calls may remain, new calls are prohibited. The plugin keeps its existing reads (turn anchor + candidate listing); the migration path is a `ctx.sessionProjections` projection unit or the async paged history read this line has not shipped yet — not a plugin-side index.
82
+ - **A peer version range is never enforced at runtime**, and a physical copy nearer than the host still wins (upstream profile-resolution lookup order: "a peer version range is not an additional resolver filter"; "a nearer physical candidate wins"). So the `@deepseek-ai/schemastery` peer floor *is* the availability guarantee for `.volatile()` (#40): a plugin directory or linked checkout left holding an older copy (e.g. the pre-3.18.2 devDependency) shadows the host's 3.18.4 and fails the top-level `Config` evaluation before the plugin loads. Floor pinned at `^3.18.4`.
82
83
 
83
84
  ## Upstream (harness) issues and the plugin's no-compensation stance
84
85
 
@@ -1,103 +1,220 @@
1
1
  # File-rewind tracking boundary
2
2
 
3
- > Behavioral note — this is **not** a bug. The plugin's file rewind deliberately
4
- > tracks only the model's dedicated editing tools, and applies an additional
5
- > boundary (message-boundary re-check) to registered files. Written for issue
6
- > [#5](https://github.com/SiriLee/dsh-rewind/issues/5); aligned with Claude Code's
7
- > checkpoint semantics. [中文](tracking-boundary.zh.md)
8
-
9
- ## One-line summary
10
-
11
- Rewind **always** restores files edited with the dedicated editing tools
12
- (`write` / `edit`) because the plugin snapshots them before the edit.
13
- Command-line (or hand) edits are covered **only when the file was previously
14
- registered** by an editing tool in the same conversation — and, in that case,
15
- the change itself is recorded, but rewinding to a boundary inside an unchanged
16
- interval is only as precise as the next recorded state (see
17
- [Rewind precision](#rewind-precision-for-a-registered-file-changed-outside-editing-tools)).
18
-
19
- ## How file changes are classified
20
-
21
- **Editing tools** (create/overwrite, line edits): each call tells the
22
- plugin *which file* it will change, so the plugin snapshots the file's current
23
- content **before** the edit and can restore it on rewind. This path is
24
- deterministic — using them always yields a restorable rewind.
25
-
26
- **Command-line or manual edits**: PowerShell (`pwsh`) writes on Windows, `sed -i`
27
- on Linux, or saving a file by hand. These do **not** tell the plugin which file
28
- is being touched, so there is nothing to snapshot beforehand.
29
-
30
- Command/manual edits are nevertheless covered in one case: if the file was
31
- **previously registered** by an editing tool in this conversation, the plugin
32
- re-checks registered files at each user-message boundary and records a change it
33
- sees, so those files restore on rewind too.
34
-
35
- ## When a rewind is not available
36
-
37
- These are cases where there is **no recorded pre-change state at all** (as
38
- opposed to the precision gap in the next section, where a state exists but an
39
- intermediate message's exact value is lost):
40
-
41
- 1. **The model edits the file with a command on its first touch, and the file
42
- was never registered.** No snapshot, no registration — nothing was recorded
43
- around the change, so there is nothing to restore from. Every rewind target
44
- reports no restorable change for this file.
45
- 2. **The file was manually edited before the model's first editing-tool change.**
46
- The plugin's first snapshot is taken at the editing-tool call, so rewind can
47
- return the file only to its state *after* the manual edit, not before it.
48
-
49
- Both stem from a deliberate **lightweight** trade-off. Supporting command edits
50
- in every case would require scanning the whole workspace and classifying what
51
- arbitrary commands changed — costly, error-prone, hard to maintain. Instead the
52
- plugin registers only the editing tools that hand it an exact path, and relies
53
- on the message-boundary re-check as its lightweight safety net. The cost is that
54
- unregistered files' command edits are not recorded; the benefit is a simple,
55
- reliable, maintainable plugin — the same behavior Claude Code exhibits.
56
-
57
- ## Rewind precision for a registered file changed outside editing tools
58
-
59
- A registered file's snapshots come only from (a) an editing tool's before-capture
60
- and (b) the boundary's change-detection. So a message is a *recording point* for
61
- a file only when the file's state differs from its most recent recorded state;
62
- rewinding to message *M* restores each file to the earliest entry at/after *M*.
63
-
64
- If the file was **unchanged** at *M* (relative to its last record), then *M* is
65
- **not** a recording point. If the file is later changed by a command/manual
66
- edit, the earliest entry at/after *M* is the one that recorded the **post-change**
67
- state — so rewinding to *M* restores that post-change state, not the state the
68
- file actually held at *M*:
69
-
70
- ```
71
- message 1 write sets f = "X" records before=null (file created)
72
- message 2 boundary records f = "X" (null → X)
73
- a command changes f "X" → "Y"
74
- message 3 boundary records f = "Y" (X → Y)
75
- rewind to message 2 → restores "X" (correct: f was "X" at message 2)
76
- ```
77
-
78
- Now insert one *unchanged* message, and the precision gap appears:
79
-
80
- ```
81
- message 1 write sets f = "X" records before=null (file created)
82
- message 2 boundary records f = "X" (null → X)
83
- message 3 reply, f still "X" NOT a recording point (unchanged)
84
- a command changes f "X" → "Y"
85
- message 4 boundary records f = "Y" (X → Y)
86
- rewind to message 3 → restores "Y" (wrong: f was "X" at message 3)
87
- ```
88
-
89
- Message 2 is a recording point, so rewinding to it recovers the pre-change state
90
- `X`; message 3 is not, so its exact state is lost once the command changes the
91
- file. Only recording every registered file at every message — even when nothing
92
- changed — would close this, at the cost of a per-message file per registered
93
- file. The plugin avoids that to stay lightweight (see
94
- [Position](#position)). Claude Code's checkpoints behave the same way.
95
-
96
- ## Position
97
-
98
- Making command edits restorable in the *unregistered-first-touch* case would
99
- mean repeatedly backing up the entire workspace, or depending on a Git working
100
- tree — both heavy, and at odds with the plugin's lightweight-snapshot design.
101
- Git is already the right tool for workspace-change management. The plugin's
102
- position is to favor **fast, conversation-scoped rewinds**, so this capability
103
- is deliberately not adopted.
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
+ ## Background
216
+
217
+ This document was originally written for issue
218
+ [#5](https://github.com/SiriLee/dsh-rewind/issues/5) and completed for issue
219
+ [#39](https://github.com/SiriLee/dsh-rewind/issues/39). See also:
220
+ [SECURITY.md](../../SECURITY.md) · [Snapshot auto-cleanup](../snapshot-auto-cleanup.md)
@@ -1,55 +1,131 @@
1
1
  # 文件回退的追踪边界
2
2
 
3
- > 行为说明——这**不是 bug**。插件的文件回退**有意只追踪模型的专用编辑工具**,并对**已登记文件**额外施加一层边界(消息边界复查)。为 issue [#5](https://github.com/SiriLee/dsh-rewind/issues/5) 编写;与 Claude Code 的 checkpoint 语义保持一致。 [English](tracking-boundary.md)
3
+ > 回退文件时,插件**只写回它确实备份过的内容**:被写类工具改动过的文件始终可以精确还原;而某一段状态**没有被备份**时,还原就会**不充分**——它宁可不动,也不猜测。
4
+ >
5
+ > 本文说明它的原理、由此产生的两项已知限制,以及为什么接受它们。这些都是**有意取舍**(与 Claude Code 的检查点语义一致),不是缺陷。
4
6
 
5
- ## 一句话概括
7
+ ## 一、文件变更的两种模式
6
8
 
7
- 回退**始终能还原**用专用编辑工具(`write` / `edit`)编辑过的文件,因为插件会在编辑前对其快照。**命令行(或手动)编辑**仅当该文件在同一会话中**先前已被某个编辑工具登记**时才会被覆盖——此时变更本身会被记录,但回退到"未变区间中间的边界"时,只能精确到**下一处被记录的状态**(见[已登记文件被编辑工具之外的方式改动时的回退精度](#已登记文件被编辑工具之外的方式改动时的回退精度))。
9
+ 文件在 DSH 中只有两种变化途径,插件对二者的可观测性完全不同。
8
10
 
9
- ## 文件变更的分类
11
+ **模型调用写类工具**(`write`、`edit`):调用参数给出了确切的文件路径,插件能在文件被覆盖**之前**捕获它的当前内容。这条路径是确定性的——一经处理,这个文件就一定可以还原。
10
12
 
11
- **编辑工具**(创建/覆盖、按行编辑):每次调用都会告诉插件*将要改动哪个文件*,因此插件会在编辑前对文件的当前内容**快照**,回退时能还原。这条路径是**确定性**的——用了就一定可还原。
13
+ **外部编辑**(命令行 Bash、PowerShell 写入、构建脚本改写,或手动在编辑器里保存):没有任何环节向插件声明被修改的路径,**不存在可以提前捕获的对象**,只能事后发现。
12
14
 
13
- **命令行或手动编辑**:Windows 上的 PowerShell(`pwsh`)写入、Linux 上的 `sed -i`,或手动保存文件。它们**不告诉**插件动了哪个文件,所以**没有可预先快照的对象**。
15
+ ## 二、原理
14
16
 
15
- 命令/手动编辑仍有一种情况会被覆盖:若该文件在本会话中**先前被某个编辑工具登记过**,插件会在每个用户消息边界复查已登记文件并记录它看到的变更,因此这类文件也能在回退时还原。
17
+ ### 一份备份 = 一条消息开始时的状态
16
18
 
17
- ## 何时无法回退
19
+ 插件不保存"工作区当前状态",保存的是**状态转变**。每份备份只回答一个问题:
18
20
 
19
- 这类是**完全没有记录到"变更前"状态**(有别于下一节的"精度缺口"——那是有记录、但某个中间消息的精确值丢失):
21
+ > **这条消息开始时,这个文件是什么?**
20
22
 
21
- 1. **模型第一次触达时用命令编辑,且该文件从未被登记。** 无快照、无登记——变更前后都没有记录,无从还原;回退到任何消息对该文件都报告"无变更可恢复"。
22
- 2. **文件在模型第一次编辑工具改动前就被手动编辑过。** 插件的首个快照是在编辑工具调用时拍的,因此回退只能把文件还原到手动编辑**之后**的状态,而非**之前**。
23
+ 内容有两种形态:`null` 表示文件**不存在**(还原时表现为删除),另一种则是完整副本。这些备份合起来就是这个会话的**快照**(按消息分组存放)。备份归属于**触发它的那条用户消息**;同一条消息的回合里,每次写类工具调用都会留下一份备份,还原时取其中**最早**的一份。
23
24
 
24
- 两者都来自**刻意求轻**的取舍。要覆盖各种命令编辑,就得扫描整个工作区、并分类任意命令改了什么——代价高、易错、难维护。插件只登记会交出手确切路径的编辑工具,并以消息边界复查作为轻量安全网。代价是未登记文件的命令编辑不被记录;收益是简单、可靠、易维护——与 Claude Code 相同。
25
+ ### 备份从哪里来:追踪列表
25
26
 
26
- ## 已登记文件被编辑工具之外的方式改动时的回退精度
27
+ 插件不扫描整个工作区,只认一张**追踪列表**:文件被写类工具处理**一次**即加入;此后每条新的用户消息到达时,只复查名单里的文件;不在名单里的文件没有任何备份。
27
28
 
28
- 已登记文件的快照只来自:(a) 编辑工具的 before 捕获,(b) 边界复查的变更检测。所以**只有"文件状态相对上次记录发生变化"的那条消息才是记录点**;回退到消息 *M* 会把各文件还原到 *M* 之后**最早**的那条记录。
29
+ 备份有两个来源:
29
30
 
30
- 若文件在 *M* 处**未变**(相对上次记录),则 *M* **不是记录点**。若随后文件被命令/手动编辑改掉,*M* 之后最早的那条记录是**变更后**状态——于是回退到 *M* 会还原成**变更后**状态,而非 *M* 时刻文件**真实**的状态:
31
+ - **写前备份**:模型调用写类工具时,在改动落盘**之前**取当前内容,记在**触发这一轮的那条用户消息**名下;
32
+ - **消息边界复查**:新消息到达时,若名单里某个文件当前的磁盘内容与**最近一份备份**不一致,就把当前内容记为**这条消息**的备份。这是外部编辑唯一的入口。
31
33
 
32
- ```
33
- 消息 1 write 设 f="X" 记录 before=null(文件新建)
34
- 消息 2 边界记录 f="X" (null→X)
35
- 命令把 f "X"→"Y"
36
- 消息 3 边界记录 f="Y" (X→Y)
37
- 回退到消息 2 → 还原成 "X" (正确:消息 2 时 f 是 "X")
38
- ```
34
+ ### 两种不产生备份的情形
39
35
 
40
- 插入一条**未变**消息,精度缺口才显现:
36
+ - **不变不存**:边界复查发现磁盘与最近一份备份一致时,这次复查不写备份;若本回合稍后写类工具改动了该文件,那份写前备份照常记录。所以"没有改动文件的消息"自己不产生备份。
37
+ - **超量不存**:准备写备份之前若发现单个文件超过体积上限(默认 8 MiB,可用 `DSH_REWIND_MAX_FILE_BYTES` 调整,设为 `0` 关闭),插件**拒绝备份**;这个文件在回到限制以下之前不会产生新的备份(旧备份仍在)。
41
38
 
42
- ```
43
- 消息 1 write 设 f="X" 记录 before=null(文件新建)
44
- 消息 2 边界记录 f="X" (null→X)
45
- 消息 3 回复,f 仍="X" 不是记录点(未变)
46
- 命令把 f "X"→"Y"
47
- 消息 4 边界记录 f="Y" (X→Y)
48
- 回退到消息 3 → 还原成 "Y" (错:消息 3 时 f 实际是 "X")
49
- ```
39
+ ### 还原:只依据备份
50
40
 
51
- 消息 2 是记录点,所以回退它能恢复**变更前**状态 `X`;消息 3 不是记录点,一旦文件被命令改掉,它那刻的精确状态就丢了。要关闭这个缺口,就得让每条边界都是快照——每条消息都记录每个已登记文件(哪怕没变)——代价是**每条消息×每个文件都写一个文件**。插件**刻意不做**,以保持轻量(见[定位](#定位))。Claude Code 的 checkpoint 同样如此。
41
+ 还原不读工作区的其他内容,也不做推断:
52
42
 
53
- ## 定位
43
+ 1. **找备份**:对每个文件,取**目标消息及其之后最早**的一份备份;目标消息自己没有备份时,就向更晚的消息找最早的那一份。
44
+ 2. **与磁盘比对**:一致 → 不做任何操作;不一致 → 写回备份的内容(`null` → 删除该文件)。
54
45
 
55
- 要把"未登记首触即命令编辑"的情形也做成可还原,意味着对整个工作区反复备份,或依赖 Git 工作树——都笨重,且与插件"轻量快照"的设计相悖。Git 才是管理工作区变更的正确工具。插件的定位是**快速、会话内回退**,因此**刻意不采纳**该能力。
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
+ 本文最初为 issue [#5](https://github.com/SiriLee/dsh-rewind/issues/5) 编写,并为 issue [#39](https://github.com/SiriLee/dsh-rewind/issues/39) 进行完善。相关:[SECURITY.md](../../SECURITY.md) · [快照自动清理](../snapshot-auto-cleanup.zh.md)
package/lib/client.js CHANGED
@@ -249,9 +249,9 @@ var CLASS = {
249
249
  guardHint: "dsh-rewind-guard-hint"
250
250
  };
251
251
  var REWIND_ICON_SVG = [
252
- '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">',
253
- ' <path d="M6.5 2.5 2.5 6.5l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
254
- ' <path d="M2.5 6.5h7a4 4 0 0 1 4 4v1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>',
252
+ '<svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true">',
253
+ ' <path d="M6.5 2.5 2.5 6.5l4 4" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>',
254
+ ' <path d="M2.5 6.5h7a4 4 0 0 1 4 4v1.5" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>',
255
255
  "</svg>"
256
256
  ].join("");
257
257
  var STYLE = `
@@ -263,7 +263,7 @@ var STYLE = `
263
263
  height: 28px;
264
264
  padding: 6px;
265
265
  border: none;
266
- border-radius: 28px;
266
+ border-radius: var(--dsw-radius-sm, 8px);
267
267
  background: transparent;
268
268
  color: var(--dsw-alias-label-tertiary);
269
269
  cursor: pointer;
@@ -1035,8 +1035,9 @@ function collectDurableTargets(snapshot, chat, hiddenSeqs) {
1035
1035
  return collectTargets(chat, hiddenSeqs);
1036
1036
  }
1037
1037
  function inboxOf(session) {
1038
- return session.projections.faceOf("inbox").getSnapshot();
1038
+ return session.projections.faceOf(INBOX_PROJECTION).getSnapshot();
1039
1039
  }
1040
+ var INBOX_PROJECTION = "inbox";
1040
1041
  function bubbleTextOf(row) {
1041
1042
  const clone = row.cloneNode(true);
1042
1043
  clone.lastElementChild?.remove();
@@ -1088,9 +1089,12 @@ function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchC
1088
1089
  (0, import_react.useLayoutEffect)(() => {
1089
1090
  let active = true;
1090
1091
  let queued = false;
1092
+ let unsubscribeInbox;
1093
+ let inboxOwner;
1091
1094
  const refresh = () => {
1092
1095
  if (!active) return;
1093
1096
  const session = sessionOf(sessionId);
1097
+ syncInboxSubscription(session);
1094
1098
  if (session === void 0) {
1095
1099
  setTargets([]);
1096
1100
  return;
@@ -1116,12 +1120,27 @@ function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchC
1116
1120
  refresh();
1117
1121
  });
1118
1122
  };
1123
+ const syncInboxSubscription = (session) => {
1124
+ if (session === inboxOwner) return;
1125
+ unsubscribeInbox?.();
1126
+ unsubscribeInbox = void 0;
1127
+ inboxOwner = session;
1128
+ if (session === void 0) return;
1129
+ try {
1130
+ unsubscribeInbox = session.projections.faceOf(INBOX_PROJECTION).subscribe(queueRefresh);
1131
+ } catch (error) {
1132
+ rewindLog.warn("portals", "inbox projection subscribe failed", error);
1133
+ }
1134
+ };
1119
1135
  refresh();
1120
1136
  const observer = new MutationObserver(queueRefresh);
1121
1137
  observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
1122
1138
  return () => {
1123
1139
  active = false;
1124
1140
  observer.disconnect();
1141
+ unsubscribeInbox?.();
1142
+ unsubscribeInbox = void 0;
1143
+ inboxOwner = void 0;
1125
1144
  };
1126
1145
  }, [sessionId, sessionOf]);
1127
1146
  return targets.map((target) => (0, import_react_dom.createPortal)(
@@ -1252,9 +1271,9 @@ function createRewindBridge(deps) {
1252
1271
  }
1253
1272
 
1254
1273
  // src/client/build-info.ts
1255
- var PLUGIN_VERSION = true ? "0.14.0-beta.2" : "dev";
1274
+ var PLUGIN_VERSION = true ? "0.14.0" : "dev";
1256
1275
  var PLUGIN_PACKAGE = "dsh-rewind-plugin";
1257
- var BUILD_HASH = true ? "1afddb1d" : "dev";
1276
+ var BUILD_HASH = true ? "09b74c90" : "dev";
1258
1277
 
1259
1278
  // src/client/locales.ts
1260
1279
  var zh = {
package/lib/index.js CHANGED
@@ -20,6 +20,9 @@ var en = {
20
20
  "plan.restore": "restore {path}",
21
21
  "plan.delete": "delete {path}",
22
22
  "plan.noChanges": "No restorable changes after the target.",
23
+ "plan.skip": " not restored: {path} ({reason})",
24
+ "skipReason.oversized": "over the {limit} backup limit, so the recorded state predates untracked edits",
25
+ "uncovered.header": "{count} tracked file(s) exceed the {limit} backup limit and are not covered:",
23
26
  "error.invalidTarget": 'Cannot parse target "{raw}" (expected <index> or @<seq>)',
24
27
  "failures.suffix": "; {count} file(s) failed to restore: {list}",
25
28
  "failures.item": "{path} ({message})",
@@ -35,7 +38,8 @@ var en = {
35
38
  "success": "Withdrawn seq {targetSeq} and everything after it (conversation returned to earlier){restore}.",
36
39
  "noUserMessages": "This session has no rewindable user messages yet.",
37
40
  "chooseMode": "Rewind to {target}. Choose a mode:\n /rewind {target} chat conversation only\n /rewind {target} both conversation + file restore",
38
- "command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)",
41
+ "command.description": "Rewind the conversation and/or code to a previous point",
42
+ "command.undoDescription": "Alias of /rewind",
39
43
  "cleanup.description": "Manage automatic cleanup of session snapshot backups",
40
44
  "cleanup.inputHint": "on | off | max-age <days> | run [--apply] [--current]",
41
45
  "cleanup.status": "Auto-cleanup: {state}. Max age: {days} day(s).",
@@ -69,6 +73,9 @@ var zh = {
69
73
  "plan.restore": "\u8FD8\u539F {path}",
70
74
  "plan.delete": "\u5220\u9664 {path}",
71
75
  "plan.noChanges": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u9700\u8981\u8FD8\u539F\u7684\u53D8\u66F4\u3002",
76
+ "plan.skip": " \u672A\u8FD8\u539F\uFF1A{path}\uFF08{reason}\uFF09",
77
+ "skipReason.oversized": "\u5DF2\u8D85\u8FC7 {limit} \u7684\u5907\u4EFD\u4E0A\u9650\uFF0C\u8BB0\u5F55\u7684\u72B6\u6001\u65E9\u4E8E\u672A\u88AB\u8DDF\u8E2A\u7684\u4FEE\u6539",
78
+ "uncovered.header": "{count} \u4E2A\u5DF2\u8DDF\u8E2A\u6587\u4EF6\u8D85\u8FC7 {limit} \u7684\u5907\u4EFD\u4E0A\u9650\uFF0C\u672A\u7EB3\u5165\u8BB0\u5F55\uFF1A",
72
79
  "error.invalidTarget": '\u65E0\u6CD5\u89E3\u6790\u76EE\u6807 "{raw}"\uFF08\u5E94\u4E3A <\u5E8F\u53F7> \u6216 @<seq>\uFF09',
73
80
  "failures.suffix": "\uFF1B{count} \u4E2A\u6587\u4EF6\u8FD8\u539F\u5931\u8D25\uFF1A{list}",
74
81
  "failures.item": "{path}\uFF08{message}\uFF09",
@@ -84,7 +91,8 @@ var zh = {
84
91
  "success": "\u5DF2\u64A4\u56DE seq {targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09{restore}\u3002",
85
92
  "noUserMessages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
86
93
  "chooseMode": "\u5C06\u56DE\u9000\u5230 {target}\u3002\u9009\u62E9\u6A21\u5F0F\uFF1A\n /rewind {target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD\n /rewind {target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6",
87
- "command.description": "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09",
94
+ "command.description": "\u56DE\u9000\u5BF9\u8BDD\u548C/\u6216\u4EE3\u7801\u5230\u4E4B\u524D\u7684\u67D0\u6761\u6D88\u606F",
95
+ "command.undoDescription": "/rewind \u7684\u522B\u540D",
88
96
  "cleanup.description": "\u7BA1\u7406\u4F1A\u8BDD\u5FEB\u7167\u5907\u4EFD\u7684\u81EA\u52A8\u6E05\u7406",
89
97
  "cleanup.inputHint": "on | off | max-age <\u5929\u6570> | run [--apply] [--current]",
90
98
  "cleanup.status": "\u81EA\u52A8\u6E05\u7406\uFF1A{state}\u3002\u6700\u5927\u4FDD\u7559\u5929\u6570\uFF1A{days} \u5929\u3002",
@@ -237,6 +245,14 @@ var PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
237
245
  var COMPARE_CHUNK_BYTES = 64 * 1024;
238
246
  var REPLACEMENT_CHAR = "\uFFFD";
239
247
  var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
248
+ var MAX_FILE_BYTES_ENV = "DSH_REWIND_MAX_FILE_BYTES";
249
+ var DEFAULT_MAX_FILE_BYTES = 8 * 1024 * 1024;
250
+ function maxFileBytesFromEnv() {
251
+ const raw = process.env[MAX_FILE_BYTES_ENV]?.trim();
252
+ if (raw === void 0 || raw === "") return void 0;
253
+ const parsed = Number(raw);
254
+ return Number.isInteger(parsed) ? parsed : void 0;
255
+ }
240
256
  var MAX_ANCHOR_GROUPS = 100;
241
257
  var CURRENT_STORE_VERSION = 2;
242
258
  var UnknownStoreVersionError = class extends Error {
@@ -323,6 +339,14 @@ var defaultProbe = {
323
339
  return { kind: "failed", message: error instanceof Error ? error.message : String(error) };
324
340
  }
325
341
  },
342
+ async size(path) {
343
+ try {
344
+ const st = await stat(path);
345
+ return st.isFile() ? st.size : void 0;
346
+ } catch {
347
+ return void 0;
348
+ }
349
+ },
326
350
  isLink: isLinkPath
327
351
  };
328
352
  function safeFileId(callId) {
@@ -652,6 +676,11 @@ var SnapshotStore = class _SnapshotStore {
652
676
  lastEntryTime = 0;
653
677
  /** Store options; `dedup` toggles in-place content dedup (default on). */
654
678
  dedup;
679
+ /**
680
+ * Per-file backup cap in bytes; `<= 0` means no cap. Capture paths consult it
681
+ * and the restore planner refuses a pre-cap record over an over-cap file.
682
+ */
683
+ maxFileBytes;
655
684
  /** Resolved checkpoint store root (absolute); see the constructor's fallback. */
656
685
  root;
657
686
  /**
@@ -677,12 +706,25 @@ var SnapshotStore = class _SnapshotStore {
677
706
  formatVersion = null;
678
707
  constructor(root, opts) {
679
708
  this.dedup = opts?.dedup ?? true;
709
+ this.maxFileBytes = opts?.maxFileBytes ?? maxFileBytesFromEnv() ?? DEFAULT_MAX_FILE_BYTES;
680
710
  this.root = root ?? process.env[SNAPSHOT_ROOT_ENV] ?? join(resolveDshHome(opts?.dshHome), SNAPSHOT_DIR_NAME);
681
711
  }
682
712
  /** Absolute path of one session's snapshot directory (id sanitized). */
683
713
  sessionDir(sessionId) {
684
714
  return join(this.root, safeSessionId(sessionId));
685
715
  }
716
+ /**
717
+ * True when `size` bytes is over the per-file cap, so it must not be backed
718
+ * up. `undefined` (a backend that cannot report a size) answers false: the
719
+ * guard fails toward STORING, never toward calling a file too big on a guess.
720
+ */
721
+ isOverFileCap(size) {
722
+ return this.maxFileBytes > 0 && size !== void 0 && size > this.maxFileBytes;
723
+ }
724
+ /** The cap in force, for reporting (0 = unlimited). */
725
+ get fileCapBytes() {
726
+ return this.maxFileBytes;
727
+ }
686
728
  /** Absolute path of one anchor group directory. */
687
729
  anchorDir(sessionId, anchorSeq) {
688
730
  return join(this.sessionDir(sessionId), String(anchorSeq));
@@ -1035,6 +1077,10 @@ var SnapshotStore = class _SnapshotStore {
1035
1077
  skipped.push(entry.path);
1036
1078
  continue;
1037
1079
  }
1080
+ if (probe.size !== void 0 && this.isOverFileCap(await probe.size(entry.path))) {
1081
+ skipped.push(entry.path);
1082
+ continue;
1083
+ }
1038
1084
  if (source !== null && source.kind === "lossyText") {
1039
1085
  const same2 = await probe.matches(source, entry.path);
1040
1086
  if (same2 !== true) skipped.push(entry.path);
@@ -1888,6 +1934,21 @@ var SnapshotStore = class _SnapshotStore {
1888
1934
  }
1889
1935
  return paths;
1890
1936
  }
1937
+ /**
1938
+ * The session's recorded paths whose current size is over the cap, so no
1939
+ * backup of that state exists. One `stat` per recorded path; a path first seen
1940
+ * over the cap was never recorded and is therefore not listed.
1941
+ */
1942
+ async uncoveredPaths(sessionId, probe = defaultProbe) {
1943
+ const sizeOf = probe.size?.bind(probe);
1944
+ if (this.maxFileBytes <= 0 || sizeOf === void 0) return [];
1945
+ const uncovered = [];
1946
+ for (const path of await this.trackedPaths(sessionId)) {
1947
+ const size = await sizeOf(path);
1948
+ if (this.isOverFileCap(size)) uncovered.push({ path, ...size !== void 0 ? { size } : {} });
1949
+ }
1950
+ return uncovered;
1951
+ }
1891
1952
  /**
1892
1953
  * Summarize a session's on-disk footprint for a clear dry-run: anchor-group
1893
1954
  * count, committed checkpoint-entry count (one per `.json` in an anchor
@@ -2105,6 +2166,7 @@ async function reconcileTracked(store, sessionId, anchorSeq, tracked, probe = de
2105
2166
  for (const path of tracked) {
2106
2167
  try {
2107
2168
  if (await probe.isLink(path)) continue;
2169
+ if (store.isOverFileCap(await probe.size?.(path))) continue;
2108
2170
  const last = await store.lastKnownContent(sessionId, path);
2109
2171
  if (last !== void 0 && last !== null && last.kind !== "lossyText") {
2110
2172
  const same = await probe.matches(last, path);
@@ -2352,6 +2414,7 @@ async function captureBefore(fs, store, exec, pending) {
2352
2414
  });
2353
2415
  if (session === void 0) return;
2354
2416
  if (info !== void 0 && info.type !== "file") return;
2417
+ if (info !== void 0 && store.isOverFileCap(info.size)) return;
2355
2418
  const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
2356
2419
  if (info === void 0) {
2357
2420
  pending.set(key, { path: target.displayPath, backup: null });
@@ -2415,7 +2478,12 @@ function buildMarker() {
2415
2478
  function describeTarget(target) {
2416
2479
  return target.kind === "seq" ? t("describeTarget.seq", { seq: target.seq }) : t("describeTarget.index", { index: target.index });
2417
2480
  }
2418
- function formatPlan(plan, files) {
2481
+ function formatBytes(bytes) {
2482
+ if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MiB`;
2483
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KiB`;
2484
+ return `${bytes} B`;
2485
+ }
2486
+ function formatPlan(plan, files, uncovered = [], maxFileBytes = 0) {
2419
2487
  const lines = [
2420
2488
  t("plan.rewinding", { targetSeq: plan.targetSeq, count: plan.shadowedSeqs.length })
2421
2489
  ];
@@ -2427,6 +2495,11 @@ function formatPlan(plan, files) {
2427
2495
  } else {
2428
2496
  lines.push(t("plan.noChanges"));
2429
2497
  }
2498
+ if (uncovered.length > 0) {
2499
+ lines.push(t("uncovered.header", { count: uncovered.length, limit: formatBytes(maxFileBytes) }));
2500
+ for (const file of uncovered.slice(0, 5)) lines.push(` ${file.path}`);
2501
+ if (uncovered.length > 5) lines.push(" \u2026");
2502
+ }
2430
2503
  lines.push(`impact=${files.length}`);
2431
2504
  for (const file of files) {
2432
2505
  lines.push(`${file.action}:${file.path}`);
@@ -2447,6 +2520,11 @@ function renderFailures(failed) {
2447
2520
  list: failed.map((f) => t("failures.item", { path: f.path, message: f.message })).join("\u3001")
2448
2521
  });
2449
2522
  }
2523
+ function renderSkipped(outcome, uncovered, maxFileBytes) {
2524
+ const capped = new Set(uncovered.map((item) => item.path));
2525
+ const limit = formatBytes(maxFileBytes);
2526
+ return outcome.skipped.filter((path) => capped.has(path)).map((path) => t("plan.skip", { path, reason: t("skipReason.oversized", { limit }) })).join("\n");
2527
+ }
2450
2528
  async function resolveObservationTarget(fs, path) {
2451
2529
  try {
2452
2530
  return await fs.resolve(path);
@@ -2549,7 +2627,12 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
2549
2627
  const parts = [];
2550
2628
  if (outcome.restored.length > 0) parts.push(t("restore.count", { count: outcome.restored.length }));
2551
2629
  if (outcome.deleted.length > 0) parts.push(t("delete.count", { count: outcome.deleted.length }));
2552
- if (outcome.skipped.length > 0) parts.push(t("skip.count", { count: outcome.skipped.length }));
2630
+ if (outcome.skipped.length > 0) {
2631
+ parts.push(t("skip.count", { count: outcome.skipped.length }));
2632
+ const capped = await store.uncoveredPaths(agent.session.id).catch(() => []);
2633
+ const skippedLines = renderSkipped(outcome, capped, store.fileCapBytes);
2634
+ if (skippedLines !== "") parts.push(skippedLines);
2635
+ }
2553
2636
  restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : t("noRestorable");
2554
2637
  restore += renderFailures(outcome.failed);
2555
2638
  }
@@ -2602,7 +2685,8 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
2602
2685
  if (impacts === void 0) {
2603
2686
  return { kind: "error", text: t("storeUnsupported", { version: await store.readStoreVersion(session.id) ?? 0 }) };
2604
2687
  }
2605
- return { kind: "success", text: formatPlan(plan, impacts) };
2688
+ const uncovered = await store.uncoveredPaths(session.id).catch(() => []);
2689
+ return { kind: "success", text: formatPlan(plan, impacts, uncovered, store.fileCapBytes) };
2606
2690
  }
2607
2691
  if (parts[0] === "__candidates") {
2608
2692
  const candidates = listRewindCandidates(session.snapshotEvents(), session.surface.nodes);
@@ -2805,7 +2889,7 @@ function apply(ctx, config) {
2805
2889
  });
2806
2890
  yield ctx.commands.register({
2807
2891
  name: "undo",
2808
- description: t("command.description"),
2892
+ description: t("command.undoDescription"),
2809
2893
  handler: rewindHandler
2810
2894
  });
2811
2895
  yield ctx.commands.register({
@@ -284,6 +284,10 @@ interface RewindPortalsProps extends RewindBridgeDeps {
284
284
  * (one pass per mutation batch via queueMicrotask) and diffed (setState is
285
285
  * skipped when the target set is unchanged), so the plugin never runs a
286
286
  * synchronous full-transcript scan inside a commit microtask.
287
+ *
288
+ * The scan is driven by both channels its targets are made of: the DOM (rows and
289
+ * their actions containers) and the session's `inbox` projection (the pending
290
+ * occurrences — see `syncInboxSubscription`).
287
291
  */
288
292
  export declare function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchChat, t, subscribeLocale, setComposerText }: RewindPortalsProps): ReactNode;
289
293
  /** The current composer draft: the contenteditable `textContent`.
@@ -20,7 +20,15 @@ export declare const CLASS: {
20
20
  readonly popoverGhost: 'dsh-rewind-popover-ghost';
21
21
  readonly guardHint: 'dsh-rewind-guard-hint';
22
22
  };
23
- /** The ↶ glyph, drawn inline so the bundle stays dependency-free. */
23
+ /**
24
+ * The ↶ glyph, drawn inline so the bundle stays dependency-free.
25
+ *
26
+ * Size follows the 0.1.7 product-icon set (15px, as the neighbouring copy/branch
27
+ * actions in `MessageIconActions`). Stroke is 1.25 rather than upstream's
28
+ * `Regular` 1: ↶ is a single open stroke next to a dense outline-plus-fill copy
29
+ * glyph, so at 1 it reads thinned out. Rounded caps/joins stay — upstream keeps
30
+ * them on curved artwork.
31
+ */
24
32
  export declare const REWIND_ICON_SVG: string;
25
33
  /** One injected stylesheet (scoped under `.dsh-rewind-*`). */
26
- export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: 28px;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 12px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n/* Arrow navigation moves real focus, so the focused row carries the pointer's\n fill and the browser ring is suppressed: in dark mode the UA two-tone ring\n draws its dark half against the filled row and reads as a residue edge (the\n harness Menu suppresses it the same way). */\n.dsh-rewind-popover-option:focus-visible:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n outline: none;\n}\n.dsh-rewind-popover-option:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n.dsh-rewind-popover-primary:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-rewind-guard-hint {\n position: fixed;\n z-index: 1000;\n max-width: min(440px, calc(100vw - 24px));\n padding: 8px 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n\n/* ---- Snapshot-cleanup configuration form ---- */\n/* The Plugins page draws the bundle's title, icon, crumb, description, and the\n shared settings form frame (labels, save, read-only and failure lines); this\n styles only the one control the card owns: the auto-cleanup toggle row. */\n.dsh-rewind-cleanup-permission {\n display: grid;\n gap: 6px;\n padding: 12px 0;\n}\n.dsh-rewind-cleanup-toggle-row {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-cleanup-toggle-label {\n flex: 1;\n min-width: 0;\n}\n.dsh-rewind-cleanup-hint {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n";
34
+ export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: var(--dsw-radius-sm, 8px);\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 12px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n/* Arrow navigation moves real focus, so the focused row carries the pointer's\n fill and the browser ring is suppressed: in dark mode the UA two-tone ring\n draws its dark half against the filled row and reads as a residue edge (the\n harness Menu suppresses it the same way). */\n.dsh-rewind-popover-option:focus-visible:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n outline: none;\n}\n.dsh-rewind-popover-option:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n.dsh-rewind-popover-primary:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-rewind-guard-hint {\n position: fixed;\n z-index: 1000;\n max-width: min(440px, calc(100vw - 24px));\n padding: 8px 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n\n/* ---- Snapshot-cleanup configuration form ---- */\n/* The Plugins page draws the bundle's title, icon, crumb, description, and the\n shared settings form frame (labels, save, read-only and failure lines); this\n styles only the one control the card owns: the auto-cleanup toggle row. */\n.dsh-rewind-cleanup-permission {\n display: grid;\n gap: 6px;\n padding: 12px 0;\n}\n.dsh-rewind-cleanup-toggle-row {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-cleanup-toggle-label {\n flex: 1;\n min-width: 0;\n}\n.dsh-rewind-cleanup-hint {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n";
@@ -56,6 +56,9 @@ export declare const en: {
56
56
  'plan.restore': string;
57
57
  'plan.delete': string;
58
58
  'plan.noChanges': string;
59
+ 'plan.skip': string;
60
+ 'skipReason.oversized': string;
61
+ 'uncovered.header': string;
59
62
  'error.invalidTarget': string;
60
63
  'failures.suffix': string;
61
64
  'failures.item': string;
@@ -72,6 +75,7 @@ export declare const en: {
72
75
  noUserMessages: string;
73
76
  chooseMode: string;
74
77
  'command.description': string;
78
+ 'command.undoDescription': string;
75
79
  'cleanup.description': string;
76
80
  'cleanup.inputHint': string;
77
81
  'cleanup.status': string;
@@ -67,6 +67,15 @@
67
67
  */
68
68
  /** Environment variable overriding the store root (tests, exotic homes). */
69
69
  export declare const SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
70
+ /** Env override for the per-file backup cap (`0` = unlimited); tests set it too. */
71
+ export declare const MAX_FILE_BYTES_ENV = "DSH_REWIND_MAX_FILE_BYTES";
72
+ /**
73
+ * A regular file larger than this is NOT backed up. This plugin is a
74
+ * lightweight, conversation-scoped undo, not a backup system: copying a very
75
+ * large file on every edit is what grows the store without bound and saturates
76
+ * IO (issue #39). The cap is a declared limitation, not a delta scheme.
77
+ */
78
+ export declare const DEFAULT_MAX_FILE_BYTES: number;
70
79
  /** Number of newest anchor groups (user messages) kept per session. */
71
80
  export declare const MAX_ANCHOR_GROUPS = 100;
72
81
  /**
@@ -208,6 +217,12 @@ export interface FileImpact {
208
217
  /** `restore` = write the before content back; `delete` = remove the file. */
209
218
  readonly action: 'restore' | 'delete';
210
219
  }
220
+ /** A recorded path whose current on-disk size is over the backup cap. */
221
+ export interface UncoveredPath {
222
+ readonly path: string;
223
+ /** Byte size observed on disk, when the probe could read one. */
224
+ readonly size?: number;
225
+ }
211
226
  /** Outcome of one restore pass. */
212
227
  export interface RestoreOutcome {
213
228
  readonly restored: readonly string[];
@@ -352,6 +367,13 @@ export interface DiskProbe {
352
367
  * rescue area) without loading it into memory.
353
368
  */
354
369
  copy(path: string, dest: string): Promise<CopyOutcome>;
370
+ /**
371
+ * Byte size of the regular file at `path`, or `undefined` when absent or
372
+ * unreadable. Consulted before any content comparison so an over-cap file is
373
+ * skipped without being read. Optional; a missing accessor reads as "size
374
+ * unknown", which never counts as "too big".
375
+ */
376
+ size?(path: string): Promise<number | undefined>;
355
377
  /** True when the path is a symlink or a hard link (never planned/restored). */
356
378
  isLink(path: string): Promise<boolean>;
357
379
  }
@@ -455,6 +477,11 @@ export declare class SnapshotStore {
455
477
  private lastEntryTime;
456
478
  /** Store options; `dedup` toggles in-place content dedup (default on). */
457
479
  private readonly dedup;
480
+ /**
481
+ * Per-file backup cap in bytes; `<= 0` means no cap. Capture paths consult it
482
+ * and the restore planner refuses a pre-cap record over an over-cap file.
483
+ */
484
+ private readonly maxFileBytes;
458
485
  /** Resolved checkpoint store root (absolute); see the constructor's fallback. */
459
486
  readonly root: string;
460
487
  /**
@@ -481,9 +508,19 @@ export declare class SnapshotStore {
481
508
  constructor(root?: string, opts?: {
482
509
  readonly dedup?: boolean;
483
510
  readonly dshHome?: string;
511
+ /** Per-file backup cap in bytes; `<= 0` disables it. */
512
+ readonly maxFileBytes?: number;
484
513
  });
485
514
  /** Absolute path of one session's snapshot directory (id sanitized). */
486
515
  sessionDir(sessionId: string): string;
516
+ /**
517
+ * True when `size` bytes is over the per-file cap, so it must not be backed
518
+ * up. `undefined` (a backend that cannot report a size) answers false: the
519
+ * guard fails toward STORING, never toward calling a file too big on a guess.
520
+ */
521
+ isOverFileCap(size: number | undefined): boolean;
522
+ /** The cap in force, for reporting (0 = unlimited). */
523
+ get fileCapBytes(): number;
487
524
  /** Absolute path of one anchor group directory. */
488
525
  anchorDir(sessionId: string, anchorSeq: number): string;
489
526
  /** Absolute file ref (relative to the session dir) of an entry. */
@@ -868,6 +905,12 @@ export declare class SnapshotStore {
868
905
  * entries so no extra persistence is needed.
869
906
  */
870
907
  trackedPaths(sessionId: string): Promise<Set<string>>;
908
+ /**
909
+ * The session's recorded paths whose current size is over the cap, so no
910
+ * backup of that state exists. One `stat` per recorded path; a path first seen
911
+ * over the cap was never recorded and is therefore not listed.
912
+ */
913
+ uncoveredPaths(sessionId: string, probe?: DiskProbe): Promise<UncoveredPath[]>;
871
914
  /**
872
915
  * Summarize a session's on-disk footprint for a clear dry-run: anchor-group
873
916
  * count, committed checkpoint-entry count (one per `.json` in an anchor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.14.0-beta.2",
3
+ "version": "0.14.0",
4
4
  "description": "In-window conversation rewind with workspace file restore · 同窗口内对话回退并可还原工作区文件",
5
5
  "icon": "./assets/icon.svg",
6
6
  "keywords": [
@@ -92,7 +92,7 @@
92
92
  "@deepseek-ai/dsh-session": "^0.1.7-rc.2",
93
93
  "@deepseek-ai/dsh-settings": "^0.1.7-rc.2",
94
94
  "@deepseek-ai/dsh-tools": "^0.1.7-rc.2",
95
- "@deepseek-ai/schemastery": "^3.18.1"
95
+ "@deepseek-ai/schemastery": "^3.18.4"
96
96
  },
97
97
  "peerDependenciesMeta": {
98
98
  "@deepseek-ai/cordis": {
@@ -182,7 +182,7 @@
182
182
  "@deepseek-ai/dsh-tools": "0.1.7-rc.2",
183
183
  "@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.2",
184
184
  "@deepseek-ai/dsh-user-questions": "0.1.7-rc.2",
185
- "@deepseek-ai/schemastery": "3.18.3",
185
+ "@deepseek-ai/schemastery": "3.18.4",
186
186
  "@types/node": "^24.0.0",
187
187
  "@types/react": "^18.3.31",
188
188
  "@types/react-dom": "^18.3.7",