dsh-rewind-plugin 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +4 -3
- package/README.en.md +28 -42
- package/README.md +23 -36
- package/SECURITY.md +56 -24
- package/docs/architecture.md +19 -8
- package/docs/compat/audit.md +1 -1
- package/docs/compat/diagnostics.md +2 -1
- package/docs/compat/diagnostics.zh.md +2 -1
- package/docs/compat/tracking-boundary.md +6 -6
- package/docs/compat/tracking-boundary.zh.md +2 -2
- package/docs/format.md +159 -53
- package/docs/snapshot-auto-cleanup.md +8 -10
- package/docs/snapshot-auto-cleanup.zh.md +2 -4
- package/lib/client.js +77 -51
- package/lib/index.js +1036 -228
- package/lib/types/client/portals.d.ts +34 -0
- package/lib/types/locales.d.ts +1 -0
- package/lib/types/snapshot.d.ts +397 -83
- package/package.json +1 -1
package/docs/format.md
CHANGED
|
@@ -16,63 +16,105 @@ Deleting the root only removes file backups; the store rebuilds from scratch.
|
|
|
16
16
|
```
|
|
17
17
|
<root>/
|
|
18
18
|
└── <sessionId>/ # safeSessionId(sessionId)
|
|
19
|
+
├── store # store-format marker ("2")
|
|
20
|
+
├── format # DSH session-format marker (session.header.version)
|
|
19
21
|
├── <anchorSeq>/ # decimal seq of the anchoring user/message
|
|
20
|
-
│
|
|
21
|
-
└──
|
|
22
|
+
│ ├── <base>.json # one committed before-backup (metadata)
|
|
23
|
+
│ └── <base>.before # its raw byte sidecar (the before content)
|
|
24
|
+
├── .pending/ # captures staged but not yet committed
|
|
25
|
+
│ └── <base>.before
|
|
26
|
+
├── rescue/<opId>/<n>.before # one pre-restore ("rescue") copy per op
|
|
27
|
+
└── journal-<opId>.json # one restore-op journal
|
|
22
28
|
```
|
|
23
29
|
|
|
24
30
|
- `sessionId` is sanitized to `[a-zA-Z0-9._-]`; the bare values `.` and `..`
|
|
25
31
|
are replaced (`safeSessionId`) so a hostile id cannot traverse out of the
|
|
26
32
|
root.
|
|
27
|
-
- `callId` is sanitized to `[a-zA-Z0-9._-]` (`safeFileId`)
|
|
33
|
+
- `callId` is sanitized to `[a-zA-Z0-9._-]` (`safeFileId`) and extended with an
|
|
34
|
+
8-hex digest of the unsanitized id — `<base>` is
|
|
35
|
+
`<safeFileId(callId)>-<sha256(callId)[0..8]>`, so two call ids that sanitize
|
|
36
|
+
to the same name (e.g. `a:b` / `a_b`) cannot collide. Readers never infer a
|
|
37
|
+
name: every reference names its file, so pre-digest (released v1) names keep
|
|
38
|
+
resolving.
|
|
28
39
|
- `<anchorSeq>` is a decimal integer; directories with non-integer names are
|
|
29
40
|
ignored by readers.
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
- Checkpoint entries are read from those numeric directories only
|
|
42
|
+
(`<anchorSeq>/<base>.json`). Neither marker is `.json`, and a stray `.json` in
|
|
43
|
+
the session root is ignored.
|
|
44
|
+
- Journal files are recognized by the `journal-` prefix (current format) or the
|
|
45
|
+
released `restore-journal-` prefix (read-only compatibility).
|
|
46
|
+
- `store` is the session's store-format marker (a decimal version, written
|
|
47
|
+
atomically); a missing marker means the released v1 string format. `format` is
|
|
48
|
+
the DSH **session**-format marker the snapshots were anchored under (see
|
|
49
|
+
`docs/snapshot-auto-cleanup.md`) — the two are independent.
|
|
33
50
|
|
|
34
51
|
## Checkpoint entry
|
|
35
52
|
|
|
36
|
-
One JSON file per before-backup, named `<
|
|
53
|
+
One JSON file per before-backup, named `<base>.json` next to its `<base>.before`
|
|
54
|
+
sidecar:
|
|
37
55
|
|
|
38
56
|
```ts
|
|
39
|
-
interface
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
interface CheckpointEntryJson { // one `<base>.json`
|
|
58
|
+
store: 2 // on-disk store format (absent/1 = the released v1 string format)
|
|
59
|
+
callId: string // the tool call that mutated the file
|
|
60
|
+
file: string // resolved display path (absolute)
|
|
61
|
+
parent?: string // realpath of the file's directory at commit time (the location pin)
|
|
62
|
+
blob: string | null // sibling sidecar file name; null = the file was created
|
|
63
|
+
size: number // byte length of the sidecar (0 when `blob` is null)
|
|
64
|
+
mode?: number // permission bits, applied when content is restored
|
|
65
|
+
lossy?: true // recorded from v1 content that had already lost bytes
|
|
66
|
+
time: number // epoch ms, strictly increasing within a store instance
|
|
45
67
|
}
|
|
46
68
|
```
|
|
47
69
|
|
|
70
|
+
A dedup link (see below) carries `callId`, `file`, `ref`, `time` and an
|
|
71
|
+
optional `parent` instead of `blob` and `size`.
|
|
72
|
+
|
|
48
73
|
Semantics:
|
|
49
74
|
|
|
50
|
-
- **`
|
|
51
|
-
|
|
52
|
-
|
|
75
|
+
- **`blob` is the pre-edit state**: a string names the sidecar holding the exact
|
|
76
|
+
before bytes (copied raw, never through a JS string, so binary and non-UTF-8
|
|
77
|
+
files round-trip byte-exactly); `null` means the call created the file — valid
|
|
78
|
+
only with `size: 0` and never with `lossy`. The name is an invariant: it must
|
|
79
|
+
equal the entry's own file name minus `.json` plus `.before`.
|
|
80
|
+
- **`anchorSeq` is the parent directory**, deliberately not a field of the
|
|
81
|
+
entry: rewinding to message N applies every entry anchored at or after N (the
|
|
82
|
+
boundary is inclusive).
|
|
83
|
+
- **`parent` is the location pin**: the `realpath` of `dirname(file)` at commit
|
|
84
|
+
time. A restore refuses the path when its directory no longer resolves there.
|
|
85
|
+
Only the path's FINAL component is checked for links (`lstat().nlink > 1` or a
|
|
86
|
+
symlink; see `SECURITY.md`), so a repointed ancestor directory would otherwise
|
|
87
|
+
redirect the write — or the unlink of a recorded creation — outside the
|
|
88
|
+
recorded location. Absent means "no pin" and falls back to the final-component
|
|
89
|
+
check alone.
|
|
53
90
|
- **`time` is the ordering key within an anchor group**: it is monotonic per
|
|
54
91
|
store instance (bumped past the previous commit), so same-millisecond
|
|
55
92
|
commits stay capture-ordered and a re-read always picks the same "earliest"
|
|
56
93
|
entry per path.
|
|
94
|
+
- **`mode` never decides an action** (a mode-only difference is a no-op); it is
|
|
95
|
+
applied, best-effort, only when content is written back.
|
|
96
|
+
- **`lossy` marks a record that had already lost bytes** (v1 content that was
|
|
97
|
+
decoded lossily, or a materialized link to one): it is comparable but never
|
|
98
|
+
written back, so a lossy record can never overwrite a live file.
|
|
57
99
|
- Synthetic re-check entries (external edits/deletions seen at a user-message
|
|
58
100
|
boundary) use `callId = recheck-<anchorSeq>-<sha256(path) first 8 hex>`.
|
|
59
101
|
|
|
60
102
|
### Dedup link entry
|
|
61
103
|
|
|
62
104
|
A tracked file that records the same `before` content as its immediately-prior
|
|
63
|
-
entry for that path is stored as a **link** instead of a
|
|
64
|
-
carries a `ref` (the `<anchorSeq>/<
|
|
65
|
-
`
|
|
66
|
-
resolves the `ref` back to the terminal real snapshot; `
|
|
67
|
-
means "the file was created". A `ref` is validated as
|
|
68
|
-
`<digits>/<
|
|
69
|
-
hostile ref cannot escape the store root when followed
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
105
|
+
entry for that path is stored as a **link** instead of a second byte copy: the
|
|
106
|
+
entry carries a `ref` (the `<anchorSeq>/<base>.json` of that prior entry) and
|
|
107
|
+
omits `blob`/`size`, so identical content is never duplicated across entries. A
|
|
108
|
+
reader resolves the `ref` back to the terminal real snapshot; `blob: null` (i.e.
|
|
109
|
+
a recorded creation) still means "the file was created". A `ref` is validated as
|
|
110
|
+
a single-level, `<digits>/<file>.json` relative reference (no traversal) so a
|
|
111
|
+
corrupt or hostile ref cannot escape the store root when followed; because refs
|
|
112
|
+
name the actual file, a link may point at a released-v1 entry. Because links
|
|
113
|
+
reference prior entries, `prune` materializes a surviving link whose `ref` lands
|
|
114
|
+
on a group it is about to drop before deleting that group, so no kept link is
|
|
115
|
+
left dangling; the materialized entry keeps the bytes and the link's own
|
|
116
|
+
location pin, but not the referent's `mode` (a link records no permissions of
|
|
117
|
+
its own).
|
|
76
118
|
|
|
77
119
|
## Restore journal
|
|
78
120
|
|
|
@@ -80,8 +122,10 @@ One JSON file per restore operation, written **before any mutation** and
|
|
|
80
122
|
updated as the pass applies:
|
|
81
123
|
|
|
82
124
|
```ts
|
|
125
|
+
type ByteRef = { blob: string } | { text: string } // session-relative byte file, or inline text
|
|
126
|
+
|
|
83
127
|
interface RestoreJournal {
|
|
84
|
-
version:
|
|
128
|
+
version: 2
|
|
85
129
|
id: string // `op-<base36 ms>-<random>`; file name suffix
|
|
86
130
|
sessionId: string
|
|
87
131
|
targetSeq: number // rewind target the restore belongs to
|
|
@@ -94,20 +138,32 @@ interface RestoreJournal {
|
|
|
94
138
|
|
|
95
139
|
interface RestoreJournalAction {
|
|
96
140
|
path: string
|
|
97
|
-
action: 'restore' | 'delete' // restore = write
|
|
98
|
-
before:
|
|
99
|
-
rescue:
|
|
141
|
+
action: 'restore' | 'delete' // restore = write the content back; delete = unlink
|
|
142
|
+
before: ByteRef | null // target content for restore; null for delete
|
|
143
|
+
rescue: ByteRef | null // pre-restore disk state; null = file was absent
|
|
144
|
+
mode?: number // recorded target permissions
|
|
145
|
+
rescueMode?: number // recorded pre-restore permissions
|
|
146
|
+
parent?: string // checkpoint-time location pin (see the entry)
|
|
100
147
|
rescueError?: string // set when the rescue capture failed (rollback skips it)
|
|
101
148
|
done: boolean // true once the action's fs op completed and was marked
|
|
102
149
|
failed?: string // per-action failure message (the pass never aborts)
|
|
103
150
|
}
|
|
104
151
|
```
|
|
105
152
|
|
|
153
|
+
A `{blob}` reference is safe and session-relative: `<anchorSeq>/<base>.before`
|
|
154
|
+
(an entry sidecar) or `rescue/<opId>/<n>.before` (a rescue copy), validated on
|
|
155
|
+
both write and read so a corrupt or hostile journal can never point a restore or
|
|
156
|
+
a rollback outside the store. `{text}` refs carry content that has no sidecar:
|
|
157
|
+
released-v1 entry content, or a legacy journal's inline strings.
|
|
158
|
+
|
|
106
159
|
States: `running` and `rollback-running` are non-terminal; `completed` /
|
|
107
|
-
`rolled-back` are terminal.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
160
|
+
`rolled-back` are terminal. Reconciliation *reports* a still non-terminal op as
|
|
161
|
+
`interrupted` (or `recovery-required` when the journal is corrupt or a rollback
|
|
162
|
+
could not complete), while the journal itself stays `running` /
|
|
163
|
+
`rollback-running`. A journal read back from a legacy file (`restore-journal-`
|
|
164
|
+
prefix, `version: 1`, inline strings) is rewritten IN PLACE as `version: 2` once
|
|
165
|
+
a redo or rollback pass completes, so the same op never leaves two divergent
|
|
166
|
+
versions on disk.
|
|
111
167
|
|
|
112
168
|
## Write guarantees
|
|
113
169
|
|
|
@@ -116,9 +172,29 @@ complete), while the journal itself stays `running` / `rollback-running`.
|
|
|
116
172
|
file — never a readable half-written target — and readers ignore temp files
|
|
117
173
|
(they do not end in `.json`). The next write of the same target overwrites
|
|
118
174
|
a leftover temp.
|
|
175
|
+
- **Bytes before metadata**: a checkpoint commit places the sidecar first
|
|
176
|
+
(a staged capture is `rename`d out of `.pending/`; other sources are written
|
|
177
|
+
temp-then-rename) and only then publishes the entry JSON, so a crash can
|
|
178
|
+
leave an unreferenced sidecar but never an entry whose bytes are missing.
|
|
179
|
+
A sidecar that is missing or shorter than `size` is a per-file failure, never
|
|
180
|
+
a silent "the file was created".
|
|
181
|
+
- **Pinned location**: every record this build writes — entry, link and journal
|
|
182
|
+
action — carries where the tracked file's directory resolved at commit time
|
|
183
|
+
(the `parent` pin, best-effort), and a restore re-checks it before touching the
|
|
184
|
+
path — the initial pass, a post-restart `continueRestore` and a
|
|
185
|
+
`rollbackRestore` alike (the journal carries the pin). A directory that
|
|
186
|
+
resolves elsewhere is refused and reported, so a restore can never write or
|
|
187
|
+
unlink outside the recorded location; a record with no pin (released-v1 data,
|
|
188
|
+
or a commit whose parent could not be resolved) falls back to the
|
|
189
|
+
final-component link check alone. A parent chain that is GONE is still
|
|
190
|
+
recreated — the plugin restores files whose directory was deleted — but only
|
|
191
|
+
while its nearest surviving ancestor resolves inside the pin. A stable
|
|
192
|
+
symlinked ancestor is never refused: both sides of the comparison are
|
|
193
|
+
`realpath`s.
|
|
119
194
|
- **Journal before mutation**: the rescue state of every planned path is
|
|
120
|
-
captured and the intent journal
|
|
121
|
-
mutation; each action is marked
|
|
195
|
+
captured as a raw byte copy and the intent journal — references only —
|
|
196
|
+
persisted atomically BEFORE the first fs mutation; each action is marked
|
|
197
|
+
`done` as it is applied.
|
|
122
198
|
- **Disk is truth**: after a restart, reconciliation compares the real disk
|
|
123
199
|
against each action's goal (the restore target for `running` journals, the
|
|
124
200
|
rescue state for `rollback-running` / `recovery-required` ones). A path
|
|
@@ -126,32 +202,62 @@ complete), while the journal itself stays `running` / `rollback-running`.
|
|
|
126
202
|
- **Bounded storage**: `prune` keeps the newest 100 anchor groups per session
|
|
127
203
|
(`MAX_ANCHOR_GROUPS`), materializing any surviving dedup link that references
|
|
128
204
|
a group being dropped before deleting whole anchor directories; it also
|
|
129
|
-
recycles terminal journals (`completed` / `rolled-back`)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
205
|
+
recycles terminal journals (`completed` / `rolled-back`) together with their
|
|
206
|
+
`rescue/<opId>/` bytes, and collects `.pending/` captures older than 24 h that
|
|
207
|
+
no commit consumed. A group a non-terminal journal still references is pinned
|
|
208
|
+
— evicting it would make "continue finishes the interrupted op" impossible —
|
|
209
|
+
so the effective window may exceed `keep` until that op is resolved.
|
|
210
|
+
Non-terminal and corrupt journals are always kept. Across sessions,
|
|
211
|
+
`pruneStale` removes whole long-inactive session directories whose newest
|
|
212
|
+
member stamp is older than a configurable idle cutoff (default off), so the
|
|
213
|
+
store root does not grow without bound either.
|
|
134
214
|
|
|
135
215
|
## Validation and failure policy
|
|
136
216
|
|
|
137
|
-
- **Entries**: a missing or
|
|
138
|
-
skipped) — losing one backup, never the recovery path.
|
|
217
|
+
- **Entries**: a missing, malformed, or self-contradictory entry is read as
|
|
218
|
+
`undefined` (silently skipped) — losing one backup, never the recovery path.
|
|
219
|
+
Contradictions are never guessed at: a `blob: null` with `size !== 0` or with
|
|
220
|
+
`lossy`, a `blob` that is not this entry's sidecar name, and a non-string
|
|
221
|
+
`before` in a v1 record are all corruption, and guessing an entry's kind is
|
|
222
|
+
how a restore turns into a delete. An absent or malformed `parent` is not
|
|
223
|
+
corruption: it means "no pin", so the entry falls back to the released
|
|
224
|
+
final-component rule. Two cases are *not* silent skips: an entry
|
|
225
|
+
whose `store` is newer than this build fails the whole operation closed (see
|
|
226
|
+
Versioning), and a record whose sidecar is missing or too short is a per-file
|
|
227
|
+
failure that the restore reports — never a delete of the live file.
|
|
139
228
|
- **Journals**: a corrupt or schema-invalid journal **fails loud** —
|
|
140
229
|
`reconcileRestores` reports it as `recovery-required` and never drops it,
|
|
141
230
|
because dropping it would silently erase the interrupted restore's recovery
|
|
142
|
-
record.
|
|
231
|
+
record. A `ref` with a traversal, absolute, or unknown-root segment counts as
|
|
232
|
+
corrupt.
|
|
143
233
|
- **Journal IO**: best-effort by design — if a journal cannot be written, the
|
|
144
234
|
restore proceeds with pre-journal semantics (crash safety degrades,
|
|
145
235
|
behavior does not).
|
|
146
236
|
|
|
147
237
|
## Versioning policy
|
|
148
238
|
|
|
149
|
-
The
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
239
|
+
The format is versioned twice: a session-level `store` marker (the value is the
|
|
240
|
+
current `CURRENT_STORE_VERSION`, 2) and a self-describing field on every record
|
|
241
|
+
(`store` on entries, `version` on journals). A missing `store` marker (or `1`)
|
|
242
|
+
means the released v1 string format, which is still read but never migrated. A
|
|
243
|
+
`store` **above** the current version fails the whole operation closed — no file
|
|
244
|
+
restore and no new entry written into a store a newer build owns (an explicit
|
|
245
|
+
clear or the age-based sweep is not version-gated) — while the conversation
|
|
246
|
+
rewind itself keeps working (it does not depend on snapshots). A journal whose
|
|
247
|
+
`version` is present but neither 1 nor 2 is corrupt and is reported
|
|
248
|
+
`recovery-required` (see Validation).
|
|
249
|
+
|
|
250
|
+
Because compatibility is not safe in both directions, the byte format reuses
|
|
251
|
+
none of v1's path/state keys (`path`, `anchorSeq`, `before`) and does not carry
|
|
252
|
+
`anchorSeq` (it equals the parent directory). A released v1 build therefore
|
|
253
|
+
rejects each such entry instead of reading it as "the file was created", so a
|
|
254
|
+
downgrade cannot delete workspace files; that field-name contract is pinned by
|
|
255
|
+
`tests/downgrade-safety.test.ts`. Compatibility means reading old data, not
|
|
256
|
+
repairing it: bytes the v1 build had already lost cannot be recovered.
|
|
257
|
+
|
|
258
|
+
A future incompatible format should raise the marker/version (readers already
|
|
259
|
+
fail closed above their own version) or move the state root and ship an explicit
|
|
260
|
+
migration tool. Old-format data is never silently re-interpreted.
|
|
155
261
|
|
|
156
262
|
## Cleanup policy persistence
|
|
157
263
|
|
|
@@ -50,19 +50,17 @@ The auto-cleanup switch and the idle-day cutoff live in the **dsh-settings confi
|
|
|
50
50
|
|
|
51
51
|
## When automatic cleanup runs
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
what you're doing.
|
|
59
|
-
|
|
60
|
-
If you want a change to take effect right away, use `run`; automatic cleanup
|
|
61
|
-
picks up a fresh setting on the next run.
|
|
53
|
+
When it's enabled, automatic cleanup runs in the background at most **once a
|
|
54
|
+
day** — and only after 24 hours have passed since the last cleanup. The clock is
|
|
55
|
+
saved to `<dsh home>/snapshot-cleanup-last-sweep.json`, so restarting the host
|
|
56
|
+
can't make it run early. To clean up right now instead of waiting, use
|
|
57
|
+
`/snapshot-auto-cleanup run`.
|
|
62
58
|
|
|
63
59
|
## Safety
|
|
64
60
|
|
|
65
|
-
- Only rewind **snapshots**
|
|
61
|
+
- Only rewind **snapshots** are ever removed: a session's snapshot directory
|
|
62
|
+
holds the before-write file backups plus their bookkeeping (staged captures,
|
|
63
|
+
rescue copies, restore journals), and that is all the cleanup touches. Your
|
|
66
64
|
conversation is never touched, and the plugin never rewrites or deletes your
|
|
67
65
|
session history.
|
|
68
66
|
- Automatic cleanup never removes your **active** session's snapshots — only
|
|
@@ -38,13 +38,11 @@
|
|
|
38
38
|
|
|
39
39
|
## 自动清理何时运行
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
想让改动立即生效,请用 `run`;自动清理要到下次运行才会采用新设置。
|
|
41
|
+
开启后,自动清理在后台运行,**一天最多一次**——且距上次清理已满 24 小时才会执行。这个时钟保存在 `<dsh home>/snapshot-cleanup-last-sweep.json`,所以重启宿主也不会让它提前运行。想立刻清理而不等待,用 `/snapshot-auto-cleanup run`。
|
|
44
42
|
|
|
45
43
|
## 安全
|
|
46
44
|
|
|
47
|
-
-
|
|
45
|
+
- 只会移除回退用的**快照**:一个会话的快照目录里只有写前文件备份及其附属记录(暂存副本、救援副本、还原日志),清理也只动这些。你的对话绝不会被动到,插件也绝不改写或删除你的会话历史。
|
|
48
46
|
- 自动清理**永不**移除你**当前活跃**会话的快照——只清理已失活超过阈值的会话。
|
|
49
47
|
- `run --current` 会清空当前会话的快照。这对该会话的文件回退历史是**不可逆**的:你无法回退到清空之前的代码状态,但你的对话保持完整,会话会从"现在"开始重新记录快照。
|
|
50
48
|
|
package/lib/client.js
CHANGED
|
@@ -167,6 +167,48 @@ function rewindOptionsFromCandidates(candidates, t) {
|
|
|
167
167
|
}));
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// src/client/log.ts
|
|
171
|
+
var NS = "dsh-rewind";
|
|
172
|
+
var DEBUG_KEY = "dsh-rewind.debug";
|
|
173
|
+
var ALWAYS_ON = /* @__PURE__ */ new Set(["error", "warn"]);
|
|
174
|
+
function switchValue() {
|
|
175
|
+
try {
|
|
176
|
+
return window.localStorage.getItem(DEBUG_KEY) ?? "";
|
|
177
|
+
} catch {
|
|
178
|
+
return "";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function matches(value, ns) {
|
|
182
|
+
for (const entry of value.split(",")) {
|
|
183
|
+
const part = entry.trim();
|
|
184
|
+
if (part === "") continue;
|
|
185
|
+
if (part === "*" || part === `${NS}*`) return true;
|
|
186
|
+
if (part.endsWith("*")) {
|
|
187
|
+
if (ns.startsWith(part.slice(0, -1))) return true;
|
|
188
|
+
} else if (ns === part) {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
function tag(scope) {
|
|
195
|
+
return `[${NS}:${scope}]`;
|
|
196
|
+
}
|
|
197
|
+
function log(level, scope, message, data) {
|
|
198
|
+
if (ALWAYS_ON.has(level)) {
|
|
199
|
+
console[level](tag(scope), message, data);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!matches(switchValue(), `${NS}:${scope}`)) return;
|
|
203
|
+
console.info(tag(scope), message, data);
|
|
204
|
+
}
|
|
205
|
+
var rewindLog = {
|
|
206
|
+
error: (scope, message, data) => log("error", scope, message, data),
|
|
207
|
+
warn: (scope, message, data) => log("warn", scope, message, data),
|
|
208
|
+
info: (scope, message, data) => log("info", scope, message, data),
|
|
209
|
+
debug: (scope, message, data) => log("debug", scope, message, data)
|
|
210
|
+
};
|
|
211
|
+
|
|
170
212
|
// src/client/styles.ts
|
|
171
213
|
var CLASS = {
|
|
172
214
|
button: "dsh-rewind-btn",
|
|
@@ -663,8 +705,21 @@ function isPreviewFor(node, seq) {
|
|
|
663
705
|
}
|
|
664
706
|
async function previewImpact(session, chatOf, seq, watch) {
|
|
665
707
|
const known = knownCommandSeqs(session, chatOf, (node) => isPreviewFor(node, seq));
|
|
666
|
-
|
|
667
|
-
|
|
708
|
+
let result;
|
|
709
|
+
try {
|
|
710
|
+
result = await session.command(`/rewind preview @${seq} both`);
|
|
711
|
+
} catch (error) {
|
|
712
|
+
rewindLog.warn("preview", `preview command threw for @${seq}`, error);
|
|
713
|
+
return { kind: "error", text: error instanceof Error ? error.message : String(error) };
|
|
714
|
+
}
|
|
715
|
+
if (!result.ok) {
|
|
716
|
+
rewindLog.warn("preview", `preview command rejected for @${seq}`, result.error);
|
|
717
|
+
return { kind: "error", text: `${result.error.code}: ${result.error.message}` };
|
|
718
|
+
}
|
|
719
|
+
if (result.value?.matched !== true) {
|
|
720
|
+
rewindLog.warn("preview", `preview command was not matched for @${seq}`);
|
|
721
|
+
return { kind: "error", text: "the rewind command is not registered on this host" };
|
|
722
|
+
}
|
|
668
723
|
return waitForCommand(session, chatOf, (node) => isPreviewFor(node, seq) && !known.has(node.seq), 8e3, watch);
|
|
669
724
|
}
|
|
670
725
|
function el(tag2, className, text) {
|
|
@@ -912,13 +967,13 @@ function openPopover(opts) {
|
|
|
912
967
|
impactOutcome = outcome;
|
|
913
968
|
if (outcome !== null && outcome.kind === "success") {
|
|
914
969
|
bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
|
|
915
|
-
} else
|
|
916
|
-
bothState = { state: "error", message: outcome
|
|
970
|
+
} else {
|
|
971
|
+
bothState = { state: "error", message: outcome?.text ?? "preview command timed out" };
|
|
917
972
|
}
|
|
918
973
|
renderModes();
|
|
919
974
|
shell.position();
|
|
920
975
|
})().catch(() => {
|
|
921
|
-
bothState = { state: "
|
|
976
|
+
bothState = { state: "error", message: "unexpected error" };
|
|
922
977
|
renderModes();
|
|
923
978
|
shell.position();
|
|
924
979
|
});
|
|
@@ -948,48 +1003,6 @@ function retractSpan(steering, targetId) {
|
|
|
948
1003
|
return steering.slice(index).map((item) => item.id);
|
|
949
1004
|
}
|
|
950
1005
|
|
|
951
|
-
// src/client/log.ts
|
|
952
|
-
var NS = "dsh-rewind";
|
|
953
|
-
var DEBUG_KEY = "dsh-rewind.debug";
|
|
954
|
-
var ALWAYS_ON = /* @__PURE__ */ new Set(["error", "warn"]);
|
|
955
|
-
function switchValue() {
|
|
956
|
-
try {
|
|
957
|
-
return window.localStorage.getItem(DEBUG_KEY) ?? "";
|
|
958
|
-
} catch {
|
|
959
|
-
return "";
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
function matches(value, ns) {
|
|
963
|
-
for (const entry of value.split(",")) {
|
|
964
|
-
const part = entry.trim();
|
|
965
|
-
if (part === "") continue;
|
|
966
|
-
if (part === "*" || part === `${NS}*`) return true;
|
|
967
|
-
if (part.endsWith("*")) {
|
|
968
|
-
if (ns.startsWith(part.slice(0, -1))) return true;
|
|
969
|
-
} else if (ns === part) {
|
|
970
|
-
return true;
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
return false;
|
|
974
|
-
}
|
|
975
|
-
function tag(scope) {
|
|
976
|
-
return `[${NS}:${scope}]`;
|
|
977
|
-
}
|
|
978
|
-
function log(level, scope, message, data) {
|
|
979
|
-
if (ALWAYS_ON.has(level)) {
|
|
980
|
-
console[level](tag(scope), message, data);
|
|
981
|
-
return;
|
|
982
|
-
}
|
|
983
|
-
if (!matches(switchValue(), `${NS}:${scope}`)) return;
|
|
984
|
-
console.info(tag(scope), message, data);
|
|
985
|
-
}
|
|
986
|
-
var rewindLog = {
|
|
987
|
-
error: (scope, message, data) => log("error", scope, message, data),
|
|
988
|
-
warn: (scope, message, data) => log("warn", scope, message, data),
|
|
989
|
-
info: (scope, message, data) => log("info", scope, message, data),
|
|
990
|
-
debug: (scope, message, data) => log("debug", scope, message, data)
|
|
991
|
-
};
|
|
992
|
-
|
|
993
1006
|
// src/client/portals.tsx
|
|
994
1007
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
995
1008
|
function fillComposerEditable(text) {
|
|
@@ -1041,7 +1054,12 @@ async function runRewindAndFill(session, seq, mode, currentSessionId, chatOf, wa
|
|
|
1041
1054
|
rewindLog.warn("refill", `rewind command threw, skipping refill @${seq}`, error);
|
|
1042
1055
|
return;
|
|
1043
1056
|
}
|
|
1044
|
-
if (!result.ok
|
|
1057
|
+
if (!result.ok) {
|
|
1058
|
+
rewindLog.warn("refill", `rewind command rejected for @${seq}`, result.error);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
if (result.value?.matched !== true) {
|
|
1062
|
+
rewindLog.warn("refill", `rewind command was not matched for @${seq}`);
|
|
1045
1063
|
return;
|
|
1046
1064
|
}
|
|
1047
1065
|
let outcome;
|
|
@@ -1130,6 +1148,13 @@ function collectTargets(chat, hiddenSeqs) {
|
|
|
1130
1148
|
}
|
|
1131
1149
|
return targets;
|
|
1132
1150
|
}
|
|
1151
|
+
function isRewindInertSession(snapshot) {
|
|
1152
|
+
return snapshot.subagent !== null && snapshot.subagent !== void 0;
|
|
1153
|
+
}
|
|
1154
|
+
function collectDurableTargets(snapshot, chat, hiddenSeqs) {
|
|
1155
|
+
if (chat === void 0 || isRewindInertSession(snapshot)) return [];
|
|
1156
|
+
return collectTargets(chat, hiddenSeqs);
|
|
1157
|
+
}
|
|
1133
1158
|
function bubbleTextOf(row) {
|
|
1134
1159
|
const clone = row.cloneNode(true);
|
|
1135
1160
|
clone.lastElementChild?.remove();
|
|
@@ -1205,7 +1230,7 @@ function RewindPortals({ sessionId, sessionOf, chatOf, currentSessionId, watchCh
|
|
|
1205
1230
|
hidden.current.delete(seat);
|
|
1206
1231
|
}
|
|
1207
1232
|
}
|
|
1208
|
-
const durable =
|
|
1233
|
+
const durable = collectDurableTargets(snapshot, chat, hiddenSeqs);
|
|
1209
1234
|
const next = [...durable, ...collectPendingTargets(snapshot)];
|
|
1210
1235
|
setTargets((current) => sameTargets(current, next) ? current : next);
|
|
1211
1236
|
};
|
|
@@ -1345,8 +1370,8 @@ function createRewindBridge(deps) {
|
|
|
1345
1370
|
}
|
|
1346
1371
|
|
|
1347
1372
|
// src/client/build-info.ts
|
|
1348
|
-
var PLUGIN_VERSION = true ? "0.
|
|
1349
|
-
var BUILD_HASH = true ? "
|
|
1373
|
+
var PLUGIN_VERSION = true ? "0.12.1" : "dev";
|
|
1374
|
+
var BUILD_HASH = true ? "248f750a" : "dev";
|
|
1350
1375
|
|
|
1351
1376
|
// src/client/locales.ts
|
|
1352
1377
|
var zh = {
|
|
@@ -1705,6 +1730,7 @@ function apply(ctx) {
|
|
|
1705
1730
|
const commandUi = ctx.get("commandUi");
|
|
1706
1731
|
const hasCandidates = (sessionId) => {
|
|
1707
1732
|
const face = sessionId === void 0 ? void 0 : sessionOf(sessionId);
|
|
1733
|
+
if (face === void 0 || isRewindInertSession(face.getSnapshot())) return false;
|
|
1708
1734
|
const chat = chatOf(face);
|
|
1709
1735
|
return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
|
|
1710
1736
|
};
|