dsh-rewind-plugin 0.4.2 → 0.6.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.
@@ -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,149 @@
1
+ # Compatibility audit (compat-audit)
2
+
3
+ > Method: **the tests are the audit**. The probes in `tests/compat-invariants.test.ts`,
4
+ > `tests/compat-interop.test.ts`, `tests/compat-gaps.test.ts` and
5
+ > `scripts/verify-host.mjs` drive the plugin's real execution paths through the DSH
6
+ > subsystems' **real consumer paths** (real `@deepseek-ai/*` packages) and assert
7
+ > compatibility invariants. A probe failure is a finding; it enters the
8
+ > fix/pin/record loop.
9
+ >
10
+ > Targeted version: npm `@deepseek-ai/*@0.1.1-rc.2` (matches `package-lock.json`);
11
+ > source reference: the `oss/deepseek-harness` local fork.
12
+ >
13
+ > Version alignment: `peerDependencies` use an OR-union (e.g.
14
+ > `^0.1.0-rc.6 || ^0.1.1-rc.2`) covering each published rc tuple series. npm's
15
+ > prerelease matching rules require a candidate to share the range comparator's
16
+ > `[major, minor, patch]` tuple, so each new tuple series (e.g. a future
17
+ > `0.1.2-rc.x`, `0.2.x`) requires appending a union member; rc rolling within a
18
+ > tuple (`0.1.1-rc.2 → rc.3`) is a no-op. Signal: `npm view @deepseek-ai/dsh version`;
19
+ > flow: `scripts/check-dsh-version.mjs`.
20
+
21
+ ## Definition of "fully compatible" (invariants)
22
+
23
+ | Invariant | Meaning | Probe location |
24
+ |---|---|---|
25
+ | I1 log replayability | A rewound log passes token-meter replay and `Session.create` (the resume-preflight validation) without throwing | `compat-invariants` I1, `verify-host` 12/13 |
26
+ | I2 surface consistency | After a cut, the surface has no duplicate nodes, every node exists in the log, the withdrawn target never returns to the surface, and `deriveMessages()` is legal | `compat-invariants` I2 |
27
+ | I3 step/turn structure | Client turn-tail ordering, unique `step/start`, every `step/end` and `assistant/message` has a paired `step/start`, no ghost turns | `compat-invariants` I3, `helpers.assertTurnTailOrdering` |
28
+ | I4 fold-service safety | stats / title / goal / projection fold a marker-bearing log without throwing, with predictable values | `compat-invariants` I4 |
29
+ | I5 compact interop | A tool-call orphaned by a cancelled turn is pair-balanced once shadowed by a rewind; a rewind across a compaction checkpoint is explicitly refused; a rewind-then-compact transaction stays legal | `compat-interop` I5, `verify-host` 12/14 |
30
+ | I6 tool pipeline | before-snapshot capture/commit/restore is correct (existing `snapshot.test.ts` + `verify-host` 4–8); cancellation timing never hangs | `verify-host` 4–8, 15 |
31
+ | I7 client ordering | A log carrying tool turns, rewind markers, and ghost steps satisfies the client builder ordering | `compat-interop` I7 |
32
+ | I8 runtime safety | `rewind`/`compact` combinations never leave a dangling step/turn frame | `verify-host` 15 |
33
+
34
+ ## Verified-compatible surfaces (probes pass)
35
+
36
+ - **token-meter replay** (marker + ghost-step frames + multiple rewinds + interleaved real turns + compact stacking).
37
+ - **compaction transactions**: `toolPairingBalancedBefore/After` stays balanced after a marker cut; the real `/compact` command (`command-compact` + `compaction-basic`, stub summarizer) can land `compaction/start…end` on top of a rewind marker and stay replayable; `/compact` is a legal no-op on a small surface.
38
+ - **resume replay**: `Session.create(id, events)` replays a rewind/compact-bearing log.
39
+ - **session-stats**: a ghost-step frame adds one step but no phantom turn (reuses the turn number).
40
+ - **session-title / goal fold**: a marker does not disturb `foldSessionTitle` / `foldGoal`.
41
+ - **client ordering**: turn-tail ordering + `step/start` uniqueness hold for tool turns + marker logs.
42
+ - **rewind across a compact checkpoint**: `RewindError('not-on-surface')` refuses cleanly, no crash.
43
+ - **plan-mode**: `hasOpenTurn` pairs only `turn/start`/`turn/end`; a marker produces no `turn/start`, so a rewind during active plan is unaffected (static review confirmed).
44
+ - **agent-loop cancellation**: `finally` guarantees step/turn closure; the rewind force-stop path leaves no dangling frame.
45
+
46
+ ## Known behavior boundaries (deterministic differences, non-crash, documented)
47
+
48
+ - **session-stats / session-telemetry fold the full log**: post-rewind stats do **not** rewind — `turns`/`steps`/`llmMs` still include withdrawn content; telemetry reports the marker and ghost-step frames one-by-one (under the reused old turn). This is the intended "fold the full log" semantics, pinned by probe.
49
+ - **token-meter usage anchor briefly reverts** (G3): after a rewind, the baseline falls back to the heuristic `estimated` until the next real-usage call restores it. Confirmed expected behavior (a marker inherently carries no usage); pinned by `compat-gaps` G3.
50
+ - **Withdrawn content stays searchable/exportable**: session-query full-text and `/export` read the raw log; a rewind cuts only the surface, so withdrawn messages remain (declared in the README).
51
+ - **Session title auto-regeneration**: the title derives from the surface, so an automatically-derived title may change after a rewind.
52
+ - **Files written but uncommitted in a cancelled turn**: a `both` rewind cannot restore them (tool side-effect timing; same as Claude Code).
53
+ - **Attachment files left after a message is shadowed**: attachment storage is not cleaned with the surface (`dsh-attachment-local` not installed, not automatically verified).
54
+
55
+ ## Upstream (harness) issues and the plugin's no-compensation stance
56
+
57
+ The plugin treats these as harness-side defects it does not compensate for. Each entry records the harness issue, its current status, and the plugin's stance, so a future maintainer does not "fix the wrong direction."
58
+
59
+ ### RU-I18N: host-side locale preference is not reliably readable at command registration (harness-side structural timing defect; plugin reads once, never retries)
60
+
61
+ - **Root cause (harness-side)**: the plugin resolves `activeLocale` in an
62
+ `ctx.inject(['settings'])` callback that reads `settings.get(settingsNamespace('locale')).preference`
63
+ **once, with no retry** (`src/index.ts`). `dsh-client-locale`'s host half
64
+ registers that `locale` settings section **through its own `ctx.inject(['settings'])`**
65
+ (`packages/client/locale/src/index.ts`). Both callbacks wait only on `settings` and are
66
+ independent of each other, so cordis makes **no ordering guarantee** between the
67
+ section registration and the plugin's read.
68
+ - **Observed failure**: a probe confirmed that when the plugin's read runs, `settings.get('locale')`
69
+ returns `undefined` (the section is not yet registered), so `activeLocale` stays at the
70
+ default `'en'` and — because the read is one-shot — is never corrected. The result is that
71
+ **all host-side `t()` output (runtime messages and command descriptions alike) renders in
72
+ English**, regardless of the user's language preference.
73
+ - **Not a user-config problem**: `settings.yaml` correctly carries `locale.preference: zh`;
74
+ reading it directly works. The defect is purely the host-side read racing the section
75
+ registration.
76
+ - **Plugin stance**: no compensation. The plugin does not add a lazy re-read, a retry loop, or a
77
+ post-locale description re-registration as a workaround, because the guarantee belongs to the
78
+ harness (make the locale preference available before plugin registration, or support per-locale
79
+ command descriptions). The plugin's `t()` design is retained; localized host output is treated
80
+ as an upstream capability to be restored when the harness provides it. (Command descriptions
81
+ are English for **every** host command — system plugins also pass raw English `description`
82
+ strings, e.g. `/goal` — so this behavior is consistent with the ecosystem, not a plugin
83
+ deviation.)
84
+
85
+ ### R-OPENSTEP: an unclosed `step` in the log lets a rewind break token-meter replay (harness-side; plugin guard attempted and reverted)
86
+
87
+ > **Root cause (harness-side)**: an unclosed step left by a crash makes token-meter replay
88
+ > reject any later step activity. DSH `0.1.1-rc.2` now auto-closes crash-left step/turn/tool
89
+ > boundaries at load via `interruptedTurnClosers` (`dsh-session`, consumed by
90
+ > `session-persistence/src/coordinator.ts`) — **the crash path is fixed**.
91
+ >
92
+ > **Plugin guard (attempted and reverted)**: a `hasOpenStep` + `planRewind` pre-refusal was
93
+ > implemented (`open-step`) but misjudged on **real session logs** (normal rewinds refused, GUI
94
+ > verification broken), and was reverted (`177ec14`). Conclusion: **the plugin sets no guard**,
95
+ > accepting residual risk (an unclosed step produced by a third-party plugin can break
96
+ > `/compact` — that log is already abnormal, and continuing the conversation triggers the same).
97
+ > The fix direction is in the harness (token-meter recovery for unclosed steps), not the plugin.
98
+
99
+ #### Concrete `step/start` trigger paths (source-confirmed)
100
+
101
+ The tree has exactly **one** `append('step/start')` producer: `packages/core/agent-loop/src/agent.ts:279`
102
+ (no other producer inside the official packages; `session/end-seed` etc. only truncate torn writes,
103
+ not logically-unclosed steps).
104
+
105
+ | # | Trigger path | Plausibility | Basis |
106
+ |---|---|---|---|
107
+ | P1 | **Abnormal process termination**: `step/start` is batched to disk (write-behind, `maxDelayMs` per batch) → the step is mid-execution (LLM stream/tool, seconds to minutes) → SIGKILL / OOM-kill / power loss / WSL hard-close → `step/end` (in `finally`, only runs while the process is alive) is never persisted | **Most realistic** | `agent.ts:292` finally; write-behind batching; torn-write fix truncates only a half-written line |
108
+ | P2 | **Third-party plugin bug**: only the official agent-loop produces one, but external plugins may `session.append('step/start', …)` and never close it | possible | public `Session.append` |
109
+ | P3 | **Manual session-file editing**: edit `~/.dsh/…/session.jsonl[.zstd]` (zstd needs decompress/recompress; plaintext config edits directly) | possible but laborious | `persistence-jsonl/format.ts` (`JsonlCompression = 'zstd' \| 'none'`) |
110
+ | P4 | **append itself failing**: `append('step/end')` in `finally` throws (payload is plain numbers, nearly impossible) | theoretical | `agent.ts:292` |
111
+
112
+ **Amplifier (rewind is not the only trigger)**: after a crash resume, agent-loop `turn()` opens a
113
+ new turn at `phase.turn + 1` (`agent.ts:251-255`) **without closing the leftover step** — so
114
+ "continue the conversation" (a new `step/start`) trips the same token-meter check. Scope:
115
+
116
+ - **The conversation itself is unaffected** (the request path does not call `tokenMeter.measure`; only compaction-basic does tree-wide).
117
+ - **Manual `/compact` fails permanently** (`compactNow`'s first `measure()` throws the raw error).
118
+ - **Automatic compaction silently stays disabled** (the `agent/pre-step` hook catches and warns; the conversation continues).
119
+ - **rewind's role**: if the user rewinds first (rather than continuing), the ghost `step/start` becomes the first trip-wire, and the plugin has no defensive detection — upgrading a "locally abnormal log" into a "user-visible `/compact` failure."
120
+
121
+ ## Uncovered boundaries (need an additional e2e layer; non-blocking)
122
+
123
+ - Real LLM streaming and auto title generation (L2 stubbed).
124
+ - Real SQLite index lifetime (`dsh-session-query-sqlite` not installed; native deps).
125
+ - Actual browser rendering replay (the client contract's logic layer is covered by `client-contract.test.ts`).
126
+ - Real JSONL persistence round-trip (`dsh-session-persistence-jsonl` depends on native `koffi`; the round-trip validates the harness's own zstd/JSON codec, the plugin is not party to it, not worth the cost, not installed).
127
+ - `session-reference`'s `SessionReferenceResolver.prepare` needs a full session-query service; its data base (current-surface projection) is already covered by the G1 probe (`foldSurface`).
128
+ - telemetry pipeline (`dsh-session-telemetry-otel`) and the attachment provider (`dsh-attachment-local`).
129
+ - Running workflow/jobs cancelled by a rewind: the tool contract requires observing `exec.signal` and settling (`packages/core/tools/src/index.ts`); a rewind triggers the harness's standard cancel, not plugin-specific — statically confirmed, real workflows untested.
130
+
131
+ ## Audit matrix (subsystem × invariant)
132
+
133
+ | DSH subsystem | I1 | I2 | I3 | I4 | I5 | I6 | I7 | I8 |
134
+ |---|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
135
+ | Session core (append/surface/deriveMessages) | ✓ | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
136
+ | token-meter | ✓ | — | ✓ | — | ✓ | — | — | — |
137
+ | compaction (transaction/command/tool-pairing) | ✓ | — | — | — | ✓ | — | — | ✓ |
138
+ | session-stats / projection | — | — | ✓ | ✓ | — | — | — | — |
139
+ | session-title | — | — | — | ✓ | — | — | — | — |
140
+ | goal | — | — | — | ✓ | — | — | — | — |
141
+ | resume / session-query replay | ✓ | — | ✓ | — | — | — | ✓ | — |
142
+ | tool pipeline (snapshot/restore) | — | — | — | — | — | ✓ | — | ✓ |
143
+ | client ordering | — | — | ✓ | — | — | — | ✓ | — |
144
+ | plan-mode | — | — | — | — | — | — | — | ✓ (static) |
145
+
146
+ ✓ = probe passes; — = not applicable. RU-I18N and R-OPENSTEP are upstream issues the plugin
147
+ does not compensate for (see above); G3 is a confirmed-non-defect behavior pinned in
148
+ `compat-gaps.test.ts` (G1 surface classification via `foldSurface` and G2 projection checkpoint
149
+ both pass).
@@ -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。