dsh-rewind-plugin 0.4.1 → 0.5.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/CONTRIBUTING.md +72 -0
- package/README.en.md +174 -0
- package/README.md +105 -101
- package/SECURITY.md +154 -0
- package/docs/README.md +29 -0
- package/docs/architecture.md +128 -0
- package/docs/compat/tracking-boundary.md +103 -0
- package/docs/compat/tracking-boundary.zh.md +55 -0
- package/docs/compat/troubleshooting.md +41 -0
- package/docs/{troubleshooting.zh.md → compat/troubleshooting.zh.md} +15 -0
- package/docs/format.md +150 -0
- package/docs/harness-reference.md +7 -3
- package/docs/release/release.md +69 -0
- package/docs/{release.md → release/release.zh.md} +6 -52
- package/docs/snapshot-auto-cleanup.md +71 -0
- package/docs/snapshot-auto-cleanup.zh.md +42 -0
- package/lib/client.js +1 -1
- package/lib/index.js +982 -50
- package/lib/types/client/candidates.d.ts +5 -4
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/index.d.ts +3 -1
- package/lib/types/locales.d.ts +17 -0
- package/lib/types/rewind.d.ts +3 -2
- package/lib/types/snapshot-cleanup.d.ts +125 -0
- package/lib/types/snapshot.d.ts +374 -14
- package/package.json +6 -3
- package/README.zh.md +0 -159
- package/docs/troubleshooting.md +0 -44
- /package/docs/{compat-audit.md → compat/audit.md} +0 -0
- /package/docs/{client-contract.md → contract/client-contract.md} +0 -0
- /package/docs/{client-contract.zh.md → contract/client-contract.zh.md} +0 -0
package/SECURITY.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Security model
|
|
2
|
+
|
|
3
|
+
This document is the security model of **dsh-rewind** — how the plugin treats
|
|
4
|
+
untrusted input, what it is allowed to mutate, and how it survives crashes.
|
|
5
|
+
It is derived from the implementation (`src/index.ts`, `src/snapshot.ts`); if
|
|
6
|
+
this document and the code ever disagree, the code wins and this document is
|
|
7
|
+
a bug.
|
|
8
|
+
|
|
9
|
+
## Trusted boundary
|
|
10
|
+
|
|
11
|
+
The plugin runs in the DSH host process and therefore holds the host user's
|
|
12
|
+
filesystem authority — it reads and writes files with plain `node:fs`. The
|
|
13
|
+
following are treated as **untrusted inputs**:
|
|
14
|
+
|
|
15
|
+
- **Model arguments** — the `file_path` / `path` / `command` fields of write,
|
|
16
|
+
`edit`, and `str_replace_editor` tool calls (they name the paths the
|
|
17
|
+
checkpoint store records).
|
|
18
|
+
- **Session log contents** — events are parsed structurally; a hostile or
|
|
19
|
+
malformed id must never escape the store root.
|
|
20
|
+
- **Current worktree state** — restore planning reconciles against the live
|
|
21
|
+
disk, which may have been changed by anything.
|
|
22
|
+
- **Concurrent external modifications** — a restore never assumes the disk
|
|
23
|
+
still matches its records.
|
|
24
|
+
|
|
25
|
+
The DSH host (and its other plugins) is trusted; this plugin does not
|
|
26
|
+
re-verify the host's own authority boundaries.
|
|
27
|
+
|
|
28
|
+
## Mutation gates
|
|
29
|
+
|
|
30
|
+
The plugin **automatically captures** before-backups of tracked mutations, but
|
|
31
|
+
**never automatically applies** one. A workspace restore happens only through
|
|
32
|
+
an explicit user-invoked `/rewind @<seq> both` (the per-message ↶ button or the
|
|
33
|
+
command channel), and only when all of the following hold:
|
|
34
|
+
|
|
35
|
+
1. **A validated target**: `planRewind` accepts only a `user/message` seq that
|
|
36
|
+
is currently on the session surface (`parseRewindTarget` → `RewindPlan`).
|
|
37
|
+
2. **A fresh plan**: the plan is derived from the current `events` + `surface`
|
|
38
|
+
at execution time — never cached across events.
|
|
39
|
+
3. **Committed backups exist**: every planned action comes from a committed
|
|
40
|
+
checkpoint entry anchored at or after the target.
|
|
41
|
+
4. **Live-disk reconciliation**: `planRestore` compares each entry against the
|
|
42
|
+
current disk and plans only actions that would actually change it — an
|
|
43
|
+
already-matching state is a no-op (restores are idempotent).
|
|
44
|
+
5. **Exclusive execution**: a per-session in-flight guard rejects concurrent
|
|
45
|
+
rewinds; a running turn is force-cancelled (`keepInbox`) and quiescence is
|
|
46
|
+
awaited before the surface is cut.
|
|
47
|
+
6. **Session binding**: the restore reads/writes only the store of the rewound
|
|
48
|
+
session (paths are resolved display paths the session's own tools touched).
|
|
49
|
+
|
|
50
|
+
**Concurrency scope**: the in-flight guard above only serializes concurrent
|
|
51
|
+
*rewinds* of one session; it does not stop other processes from modifying the
|
|
52
|
+
workspace. `planRestore` reconciles against the live disk at plan time, but
|
|
53
|
+
there is no re-validation between planning and applying — a file changed by
|
|
54
|
+
another process in that window is overwritten by the restore. The rescue state
|
|
55
|
+
is captured at apply start, so `rollbackRestore` still returns the workspace
|
|
56
|
+
to exactly the apply-start state, and per-file failures are reported rather
|
|
57
|
+
than hidden.
|
|
58
|
+
|
|
59
|
+
A failed gate fails closed: an invalid target, a missing store, an absent
|
|
60
|
+
backup, or a cancelled invocation aborts the rewind with an error.
|
|
61
|
+
|
|
62
|
+
**Automatic store deletion (opt-in)**: unrelated to restores, an enabled
|
|
63
|
+
`snapshot-auto-cleanup` background sweep deletes whole session directories of
|
|
64
|
+
**long-inactive** sessions (default off — the store is otherwise only mutated by
|
|
65
|
+
an explicit rewind apply). It is confined to the store root, uses `lstat` (so it
|
|
66
|
+
never follows a symlink out of the root), skips the active session, and never
|
|
67
|
+
touches the conversation log. When disabled (the default), no automatic deletion
|
|
68
|
+
runs.
|
|
69
|
+
|
|
70
|
+
## Conversation integrity
|
|
71
|
+
|
|
72
|
+
The session log is **append-only** — the plugin never deletes or rewrites
|
|
73
|
+
recorded history. A rewind appends a single marker event: an **empty**
|
|
74
|
+
`assistant/message` whose `surfaceOp` replaces every surface node after the
|
|
75
|
+
target. The raw log (audit trail, search, `/export`) is untouched — only the
|
|
76
|
+
model-visible surface is cut, so the next request derives its context from the
|
|
77
|
+
target onward. The marker sits inside a ghost `step/start … step/end` frame so
|
|
78
|
+
the harness token-meter replay keeps accepting the log; a malformed marker
|
|
79
|
+
(duplicated turn/step, dangling open step) is what earlier versions produced
|
|
80
|
+
and is repaired offline (see `docs/compat/troubleshooting.md`).
|
|
81
|
+
|
|
82
|
+
## Filesystem containment
|
|
83
|
+
|
|
84
|
+
- **Store root**: `~/.dsh/rewind-snapshots/` by default (`DSH_REWIND_SNAPSHOT_DIR`
|
|
85
|
+
overrides it). The store never overlaps the workspace; deleting it only
|
|
86
|
+
removes file backups and the store rebuilds from scratch.
|
|
87
|
+
- **Path sanitization**: session ids and call ids are scrubbed to
|
|
88
|
+
`[a-zA-Z0-9._-]` (`safeSessionId` / `safeFileId`) before joining the store
|
|
89
|
+
root; `.` and `..` bare values are replaced — hostile ids cannot traverse
|
|
90
|
+
out of the root.
|
|
91
|
+
- **Never written through links**: symlinked and hard-linked paths
|
|
92
|
+
(`lstat().nlink > 1`) are skipped and reported, never restored — a symlink
|
|
93
|
+
would redirect the write outside the checkpoint, and a hard link would
|
|
94
|
+
clobber every other name of the same inode (e.g. pnpm-installed files).
|
|
95
|
+
- **Restores name only recorded paths**: the store contains resolved display
|
|
96
|
+
paths of the session's own write-class tool calls (plus boundary re-checks
|
|
97
|
+
over that same tracked set) — a restore can never write an arbitrary path.
|
|
98
|
+
- **Path resolution rule**: relative paths resolve against the session
|
|
99
|
+
workspace cwd, mirroring the fs tools' own rule (`src/session-cwd.ts`).
|
|
100
|
+
- **Bounded backups**: `prune` keeps the newest 100 anchor groups per session
|
|
101
|
+
(`MAX_ANCHOR_GROUPS`), so backup accumulation cannot grow the store without
|
|
102
|
+
bound (the exact cap is pinned in `docs/format.md`). Across sessions, the
|
|
103
|
+
opt-in `pruneStale` sweep removes whole **long-inactive** session directories
|
|
104
|
+
(measured by the newest member being idle past `maxAgeDays`); it uses `lstat`
|
|
105
|
+
(no symlink following), skips dot-prefixed temp files, and never targets the
|
|
106
|
+
active session (`keepActiveId`).
|
|
107
|
+
|
|
108
|
+
## Crash safety
|
|
109
|
+
|
|
110
|
+
- **Atomic commits**: every JSON write (checkpoint entries, restore journals)
|
|
111
|
+
goes to a sibling temp file and is renamed over the target. A host crash
|
|
112
|
+
mid-write can leave only an inert `<target>.tmp` — never a readable
|
|
113
|
+
half-written file — and readers never pick up temp files.
|
|
114
|
+
- **Journaled restores**: before mutating anything, the restore captures each
|
|
115
|
+
planned path's pre-restore ("rescue") state and persists an intent journal,
|
|
116
|
+
then marks each action done as it is applied. A crash at any point leaves
|
|
117
|
+
the journal on disk.
|
|
118
|
+
- **Disk is truth**: after a restart, `reconcileRestores` re-derives from the
|
|
119
|
+
real disk which paths already match the goal (restored) and which are
|
|
120
|
+
pending; `continueRestore` finishes the interrupted op, `rollbackRestore`
|
|
121
|
+
undoes it to the exact pre-restore state. A journal whose goal is already
|
|
122
|
+
reached auto-heals to its terminal state.
|
|
123
|
+
- **Fail-loud vs fail-soft**: a corrupt **journal** is reported
|
|
124
|
+
`recovery-required` — never silently dropped (dropping it would erase the
|
|
125
|
+
interrupted restore's recovery record). Corrupt **checkpoint entries** are
|
|
126
|
+
silently ignored (they only lose one backup, not the recovery path).
|
|
127
|
+
- **Journal IO never fails the restore**: if the journal cannot be written the
|
|
128
|
+
restore proceeds with pre-journal semantics (crash safety degrades,
|
|
129
|
+
behavior does not).
|
|
130
|
+
|
|
131
|
+
## Explicit non-goals
|
|
132
|
+
|
|
133
|
+
- This plugin does **not** sandbox other processes or stop them from changing
|
|
134
|
+
files concurrently.
|
|
135
|
+
- It does **not** provide confidentiality or tamper resistance against the
|
|
136
|
+
same operating-system user: state files are created with the process
|
|
137
|
+
default permissions (no special modes are set — a standard umask applies),
|
|
138
|
+
and the host user remains trusted.
|
|
139
|
+
- It does **not** touch git (no refs, index, or worktree operations), makes
|
|
140
|
+
**no network requests**, and does **not** access credentials.
|
|
141
|
+
- It does **not** roll back whole-log state: telemetry, search, and `/export`
|
|
142
|
+
still see the withdrawn messages (documented behavior, not a bug).
|
|
143
|
+
- It does **not** restore files written by a cancelled tool call that never
|
|
144
|
+
committed a backup.
|
|
145
|
+
- **Subagent session edits are not tracked** (Claude Code alignment): a
|
|
146
|
+
subagent runs its own session, so the files it changes are not backed up and
|
|
147
|
+
cannot be restored by a rewind of the parent session.
|
|
148
|
+
|
|
149
|
+
## Reporting
|
|
150
|
+
|
|
151
|
+
Report a vulnerability through the repository's GitHub security channel or to
|
|
152
|
+
a repository maintainer. Include: the plugin version/commit, the DSH
|
|
153
|
+
(`@deepseek-ai/*`) version, the platform, and a minimal reproduction — and
|
|
154
|
+
whether the failure happened before or after workspace mutation.
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Docs
|
|
2
|
+
|
|
3
|
+
Documentation lives under `docs/`, organized by concern. Everything here is
|
|
4
|
+
maintainer-facing unless the purpose column says otherwise. This file is the
|
|
5
|
+
index/navigation entry point.
|
|
6
|
+
|
|
7
|
+
## Index
|
|
8
|
+
|
|
9
|
+
| Path | Purpose | Audience |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `architecture.md` | Module layering, rewind/checkpoint pipelines, compatibility strategy, roadmap | maintainers |
|
|
12
|
+
| `format.md` | Durable on-disk format spec (checkpoint entries + restore journals) | maintainers |
|
|
13
|
+
| `harness-reference.md` | DeepSeek Harness interface reference + plugin source layout | maintainers |
|
|
14
|
+
| `contract/client-contract.md` | Rewind visibility contract for third-party DOM plugins (`.zh` mirror) | integrators |
|
|
15
|
+
| `compat/audit.md` | Compatibility audit: verified surfaces, recorded findings, probe matrix | maintainers |
|
|
16
|
+
| `compat/troubleshooting.md` | Known issues & repair steps (`.zh` mirror) | users / maintainers |
|
|
17
|
+
| `release/release.md` | Release workflow & DSH peer-version alignment (`.zh` mirror) | maintainers |
|
|
18
|
+
|
|
19
|
+
Repo-root docs outside `docs/`: `SECURITY.md` (security model) and
|
|
20
|
+
`CONTRIBUTING.md` (contribution guide).
|
|
21
|
+
|
|
22
|
+
## Conventions
|
|
23
|
+
|
|
24
|
+
- Bilingual docs use the `.md` / `.zh.md` file split (e.g. `contract/client-contract.md`
|
|
25
|
+
+ `contract/client-contract.zh.md`, `release/release.md` + `release/release.zh.md`).
|
|
26
|
+
- `compat/audit.md` is the single source of truth for compatibility conclusions;
|
|
27
|
+
other docs link to it instead of restating them.
|
|
28
|
+
- Cross-links are relative so the whole `docs/` directory stays relocatable — and it
|
|
29
|
+
ships intact in the npm tarball via the `docs` entry in `files` (`package.json`).
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
How dsh-rewind is built: module layering, the rewind pipeline, the checkpoint
|
|
4
|
+
pipeline, the compatibility strategy, and the roadmap. The module map below is
|
|
5
|
+
the same one in `AGENTS.md`; this document adds the wiring between the
|
|
6
|
+
modules. The durable on-disk format is specified separately in
|
|
7
|
+
`docs/format.md`, the security model in `SECURITY.md`.
|
|
8
|
+
|
|
9
|
+
## Purpose
|
|
10
|
+
|
|
11
|
+
One thing: rewind a conversation **in place** to any earlier user message —
|
|
12
|
+
never forking a session or switching windows — with an optional Claude-Code-
|
|
13
|
+
style workspace file restore (`both` mode). No network, no git operations, no
|
|
14
|
+
credentials access.
|
|
15
|
+
|
|
16
|
+
## Module layering
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
src/
|
|
20
|
+
├── index.ts host plugin: /rewind command + checkpoint pipeline
|
|
21
|
+
├── rewind.ts pure planning: target parsing, surface-range plan,
|
|
22
|
+
│ candidate listing, marker turn/step math
|
|
23
|
+
├── snapshot.ts checkpoint store: disk before-backups, journaled restore,
|
|
24
|
+
│ reconcile / continue / rollback, bounded prune
|
|
25
|
+
├── session-cwd.ts session working-directory resolution (fs-tools rule)
|
|
26
|
+
└── client/ browser half: per-message ↶ button (portal bridge),
|
|
27
|
+
mode popover, hidden-span computation, candidate parsing,
|
|
28
|
+
locales, styles
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Two dependency rules keep the design testable:
|
|
32
|
+
|
|
33
|
+
1. **`rewind.ts` is pure** — no I/O, no `Session` dependency; everything
|
|
34
|
+
derives from the event log + ordered surface, so the whole planning layer
|
|
35
|
+
is unit-testable without a host.
|
|
36
|
+
2. **`snapshot.ts` is host-independent** — it talks to the disk through plain
|
|
37
|
+
`node:fs` plus injected seams (`DiskProbe`, `DeleteFile`, a test-only
|
|
38
|
+
`crash` hook), so the store is testable without the harness.
|
|
39
|
+
|
|
40
|
+
The client never reads the DOM to derive rewind state; it consumes the host's
|
|
41
|
+
machine channels (see [Compatibility strategy](#compatibility-strategy)).
|
|
42
|
+
|
|
43
|
+
## Rewind pipeline
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
↶ button / /rewind @<seq> both
|
|
47
|
+
→ handleRewind: parseRewindTarget + planRewind (target must be a
|
|
48
|
+
user/message currently on the surface)
|
|
49
|
+
→ agent.cancel({ keepInbox: true }) if running; waitForAgentIdle
|
|
50
|
+
→ dropPendingSteering (next-step inbox only; queued messages untouched)
|
|
51
|
+
→ append ghost step frame: step/start (turn = last started turn,
|
|
52
|
+
step = that turn's next unused number) … marker … step/end
|
|
53
|
+
→ marker = empty assistant/message with
|
|
54
|
+
surfaceOp { op: 'replace', start, end } over every surface node
|
|
55
|
+
after the target (+ sourceEventSeqs = shadowed seqs)
|
|
56
|
+
→ if mode 'both': store.restoreAfter(targetSeq) + syncRestoreObservations
|
|
57
|
+
→ result text carries machine tokens (impact=<n>, restore:/delete: lines)
|
|
58
|
+
→ client: hides withdrawn rows (data-dsh-rewind-hidden), refills composer
|
|
59
|
+
with the target message's text
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Key invariants:
|
|
63
|
+
|
|
64
|
+
- **The log is append-only.** The marker is the *only* mutation: it cuts the
|
|
65
|
+
model-visible surface, never the raw history (search/export still see it).
|
|
66
|
+
- **The marker is empty** (`content: []`, derives to `null` in the model
|
|
67
|
+
context) so it renders nothing and enters no model context.
|
|
68
|
+
- **The ghost step frame exists for the token-meter**: replay requires every
|
|
69
|
+
`assistant/message` inside an open step of the same `(turn, step)`; a bare
|
|
70
|
+
marker appended while idle would throw on the next `measure()` and silently
|
|
71
|
+
break `/compact`. The turn/step numbers are chosen so they can never collide
|
|
72
|
+
with a future real turn (`markerTurnOf` / `markerStepOf` — see
|
|
73
|
+
`src/rewind.ts`).
|
|
74
|
+
- **Restore is reconciled against the live disk** (`planRestore`), so repeated
|
|
75
|
+
rewinds are idempotent and a rewind whose target state already matches is a
|
|
76
|
+
no-op.
|
|
77
|
+
|
|
78
|
+
## Checkpoint pipeline (Claude Code before-backup model)
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
tools/execute captureBefore: for write / edit / str_replace_editor
|
|
82
|
+
(mutating commands only), read the file's BEFORE state;
|
|
83
|
+
subagent edits are NOT tracked (Claude Code alignment).
|
|
84
|
+
tools/post-execute commitEntry: anchor = latest user/message seq; skip
|
|
85
|
+
failed calls; write the before-backup to the store.
|
|
86
|
+
session/event user/message boundary: reconcileTracked re-reads every
|
|
87
|
+
(user/message) tracked file and records a new before-backup for any
|
|
88
|
+
whose disk state changed since last seen — external
|
|
89
|
+
edits/deletions enter the record this way.
|
|
90
|
+
prune keeps the newest 100 anchor groups per session, storing
|
|
91
|
+
identical before-content as in-place links that are
|
|
92
|
+
materialized before their group is dropped, and recycles
|
|
93
|
+
terminal restore journals.
|
|
94
|
+
pruneStale cross-session auto-cleanup (default off): whole
|
|
95
|
+
long-inactive session dirs past the cutoff are removed;
|
|
96
|
+
the active session is never targeted.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Compatibility strategy
|
|
100
|
+
|
|
101
|
+
- **Peer ranges as OR-union of DSH rc tuples** (`^0.1.0-rc.6 || ^0.1.1-rc.2`):
|
|
102
|
+
npm's prerelease rules require the peer range to share the host's
|
|
103
|
+
`[major, minor, patch]` tuple; `scripts/check-dsh-version.mjs` detects when
|
|
104
|
+
a new tuple requires appending. See `docs/release/release.md`.
|
|
105
|
+
- **Test-driven investigation**: `tests/compat-invariants.ts` /
|
|
106
|
+
`compat-interop` / `compat-gaps` probe harness behavior and pin findings in
|
|
107
|
+
`docs/compat/audit.md`; `scripts/verify-host.mjs` runs a real end-to-end
|
|
108
|
+
rewind + `/compact` chain (the full check suite).
|
|
109
|
+
- **Stable machine channels for third parties**: `dsh-rewind-plugin/client`
|
|
110
|
+
exports the pure hidden-span computation; withdrawn rows carry
|
|
111
|
+
`data-dsh-rewind-hidden`; both are semver-protected
|
|
112
|
+
(`docs/contract/client-contract.md`). DOM coupling is minimized to a small
|
|
113
|
+
set of marked rows plus a structurally-typed slot registration.
|
|
114
|
+
|
|
115
|
+
## Roadmap
|
|
116
|
+
|
|
117
|
+
Ideas under consideration, not commitments:
|
|
118
|
+
|
|
119
|
+
- **Multi-process identity/lock**: the current in-flight guard is
|
|
120
|
+
per-process; a cross-process exclusive lock (like the change-ledger
|
|
121
|
+
competitors) would cover multiple host processes on one worktree.
|
|
122
|
+
- **Lazy-commit UX**: an explicit confirm-then-apply step (a pending state)
|
|
123
|
+
to reduce mis-touch risk on in-window rewinds, which are inherently less
|
|
124
|
+
reversible than forked branches.
|
|
125
|
+
- **Locale expansion**: client/host copy is zh/en today; the copy layer
|
|
126
|
+
(`src/locales.ts`, `src/client/locales.ts`) is already keyed for more.
|
|
127
|
+
- **Composer re-send polish**: the target text is already refilled after a
|
|
128
|
+
rewind; a first-class "edit and re-send" affordance is a small extension.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# File-rewind tracking boundary
|
|
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` / `str_replace_editor`) because the plugin snapshots them
|
|
13
|
+
before the edit. Command-line (or hand) edits are covered **only when the file
|
|
14
|
+
was previously registered** by an editing tool in the same conversation — and,
|
|
15
|
+
in that case, the change itself is recorded, but rewinding to a boundary inside
|
|
16
|
+
an unchanged 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, edit lines, insert): 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.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# 文件回退的追踪边界
|
|
2
|
+
|
|
3
|
+
> 行为说明——这**不是 bug**。插件的文件回退**有意只追踪模型的专用编辑工具**,并对**已登记文件**额外施加一层边界(消息边界复查)。为 issue [#5](https://github.com/SiriLee/dsh-rewind/issues/5) 编写;与 Claude Code 的 checkpoint 语义保持一致。 [English](tracking-boundary.md)
|
|
4
|
+
|
|
5
|
+
## 一句话概括
|
|
6
|
+
|
|
7
|
+
回退**始终能还原**用专用编辑工具(`write` / `edit` / `str_replace_editor`)编辑过的文件,因为插件会在编辑前对其快照。**命令行(或手动)编辑**仅当该文件在同一会话中**先前已被某个编辑工具登记**时才会被覆盖——此时变更本身会被记录,但回退到"未变区间中间的边界"时,只能精确到**下一处被记录的状态**(见[已登记文件被编辑工具之外的方式改动时的回退精度](#已登记文件被编辑工具之外的方式改动时的回退精度))。
|
|
8
|
+
|
|
9
|
+
## 文件变更的分类
|
|
10
|
+
|
|
11
|
+
**编辑工具**(创建/覆盖、按行编辑、插入):每次调用都会告诉插件*将要改动哪个文件*,因此插件会在编辑前对文件的当前内容**快照**,回退时能还原。这条路径是**确定性**的——用了就一定可还原。
|
|
12
|
+
|
|
13
|
+
**命令行或手动编辑**:Windows 上的 PowerShell(`pwsh`)写入、Linux 上的 `sed -i`,或手动保存文件。它们**不告诉**插件动了哪个文件,所以**没有可预先快照的对象**。
|
|
14
|
+
|
|
15
|
+
命令/手动编辑仍有一种情况会被覆盖:若该文件在本会话中**先前被某个编辑工具登记过**,插件会在每个用户消息边界复查已登记文件并记录它看到的变更,因此这类文件也能在回退时还原。
|
|
16
|
+
|
|
17
|
+
## 何时无法回退
|
|
18
|
+
|
|
19
|
+
这类是**完全没有记录到"变更前"状态**(有别于下一节的"精度缺口"——那是有记录、但某个中间消息的精确值丢失):
|
|
20
|
+
|
|
21
|
+
1. **模型第一次触达时用命令编辑,且该文件从未被登记。** 无快照、无登记——变更前后都没有记录,无从还原;回退到任何消息对该文件都报告"无变更可恢复"。
|
|
22
|
+
2. **文件在模型第一次编辑工具改动前就被手动编辑过。** 插件的首个快照是在编辑工具调用时拍的,因此回退只能把文件还原到手动编辑**之后**的状态,而非**之前**。
|
|
23
|
+
|
|
24
|
+
两者都来自**刻意求轻**的取舍。要覆盖各种命令编辑,就得扫描整个工作区、并分类任意命令改了什么——代价高、易错、难维护。插件只登记会交出手确切路径的编辑工具,并以消息边界复查作为轻量安全网。代价是未登记文件的命令编辑不被记录;收益是简单、可靠、易维护——与 Claude Code 相同。
|
|
25
|
+
|
|
26
|
+
## 已登记文件被编辑工具之外的方式改动时的回退精度
|
|
27
|
+
|
|
28
|
+
已登记文件的快照只来自:(a) 编辑工具的 before 捕获,(b) 边界复查的变更检测。所以**只有"文件状态相对上次记录发生变化"的那条消息才是记录点**;回退到消息 *M* 会把各文件还原到 *M* 之后**最早**的那条记录。
|
|
29
|
+
|
|
30
|
+
若文件在 *M* 处**未变**(相对上次记录),则 *M* **不是记录点**。若随后文件被命令/手动编辑改掉,*M* 之后最早的那条记录是**变更后**状态——于是回退到 *M* 会还原成**变更后**状态,而非 *M* 时刻文件**真实**的状态:
|
|
31
|
+
|
|
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
|
+
```
|
|
39
|
+
|
|
40
|
+
插入一条**未变**消息,精度缺口才显现:
|
|
41
|
+
|
|
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
|
+
```
|
|
50
|
+
|
|
51
|
+
消息 2 是记录点,所以回退它能恢复**变更前**状态 `X`;消息 3 不是记录点,一旦文件被命令改掉,它那刻的精确状态就丢了。要关闭这个缺口,就得让每条边界都是快照——每条消息都记录每个已登记文件(哪怕没变)——代价是**每条消息×每个文件都写一个文件**。插件**刻意不做**,以保持轻量(见[定位](#定位))。Claude Code 的 checkpoint 同样如此。
|
|
52
|
+
|
|
53
|
+
## 定位
|
|
54
|
+
|
|
55
|
+
要把"未登记首触即命令编辑"的情形也做成可还原,意味着对整个工作区反复备份,或依赖 Git 工作树——都笨重,且与插件"轻量快照"的设计相悖。Git 才是管理工作区变更的正确工具。插件的定位是**快速、会话内回退**,因此**刻意不采纳**该能力。
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
[简体中文](troubleshooting.zh.md)
|
|
4
|
+
|
|
5
|
+
## History load failure: `…turn-tail… received an update before its start Match`
|
|
6
|
+
|
|
7
|
+
Rewinds from `≤ 0.2.4` collided with the next real turn's `turn/start`, so reopening the session showed
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Failed to load history: conversation Context …:turn-tail… received an update before its start Match (internal)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
and the history vanished. Rewinds from `0.2.5` on no longer collide; already-corrupted sessions need an offline repair (the log is append-only). The repair tool (`dsh-rewind-repair`) is no longer shipped from v0.4.0 — install a pre-v0.4.0 release to get it (fully quit dsh web / host first, then):
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair
|
|
17
|
+
npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair -- --dry-run # preview only
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
It only rewrites the marker events' `data.turn` (seqs, order, and zstd frame structure intact) and backs up the original file before writing — safe to run repeatedly. From a source checkout of a pre-v0.4.0 tag: `node scripts/repair-markers.mjs` (identical flags).
|
|
21
|
+
|
|
22
|
+
## Known compatibility boundaries
|
|
23
|
+
|
|
24
|
+
Behavioral notes — none of these is a crash — verified by the compatibility
|
|
25
|
+
probe suites; the audit [audit.md](audit.md) is the source of truth and lists
|
|
26
|
+
the probes that pin each one:
|
|
27
|
+
|
|
28
|
+
- Session stats / telemetry do **not** roll back with a rewind.
|
|
29
|
+
- Withdrawn messages stay **searchable and exported** (`/export` and full-text
|
|
30
|
+
search read the raw log).
|
|
31
|
+
- Session titles may **regenerate** (title derives from the current surface).
|
|
32
|
+
- Files written by a **cancelled tool call** (write happened, no snapshot
|
|
33
|
+
commit) cannot be restored by "conversation and code".
|
|
34
|
+
|
|
35
|
+
**R-OPENSTEP** (harness-side, plugin does not guard): a session log carrying an
|
|
36
|
+
*unclosed* `step/start` (a crash before the agent loop's `finally` closed the
|
|
37
|
+
step) makes later step activity break token-meter replay, so `/compact` can
|
|
38
|
+
fail after a rewind. Harness `0.1.1-rc.2` fixes the crash path on load
|
|
39
|
+
(`interruptedTurnClosers`); a plugin-side up-front rejection was tried and
|
|
40
|
+
**reverted** (`177ec14`, false positives on real logs). Deep analysis:
|
|
41
|
+
[audit.md](audit.md) → R-OPENSTEP.
|
|
@@ -12,3 +12,18 @@ npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair -- --dry-r
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
工具只改写标记事件的 `data.turn`(保持 seq / 顺序 / zstd 帧结构不变),改前自动备份原文件——可安全重复运行。源码方式:v0.4.0 之前的 tag 下 `node scripts/repair-markers.mjs`(参数相同)。
|
|
15
|
+
|
|
16
|
+
## 已知兼容边界
|
|
17
|
+
|
|
18
|
+
以下均为**预期行为而非崩溃**(探针钉住,完整依据见 [audit.md](audit.md) 事实源):
|
|
19
|
+
|
|
20
|
+
- 会话统计 / 遥测**不回退**(折叠的是完整日志);
|
|
21
|
+
- 被撤回的消息**仍可搜索、可导出**(`/export` 与全文搜索基于原始日志);
|
|
22
|
+
- 会话标题可能**重新生成**(标题由当前 surface 派生);
|
|
23
|
+
- 被**取消的工具调用**写入的文件(无快照提交)无法由「回退对话和代码」恢复。
|
|
24
|
+
|
|
25
|
+
**R-OPENSTEP**(harness 侧,插件不设守卫):日志中存在*未闭合* `step/start`
|
|
26
|
+
(agent 循环的 `finally` 闭合 step 前崩溃)时,后续 step 活动会破坏 token-meter
|
|
27
|
+
重放,回退后 `/compact` 可能报错。harness `0.1.1-rc.2` 已在加载时自动闭合
|
|
28
|
+
(`interruptedTurnClosers`);插件曾实现前置拒绝但已回退(`177ec14`,真实日志
|
|
29
|
+
误判)。深入分析:[audit.md](audit.md) → R-OPENSTEP。
|