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/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
- └── <callId>.json # one committed before-backup
21
- └── restore-journal-<opId>.json # one restore-op journal
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
- - Journal files are recognized by the `restore-journal-` prefix; everything
31
- else ending in `.json` under the session dir is treated as a checkpoint
32
- entry.
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 `<callId>.json`:
53
+ One JSON file per before-backup, named `<base>.json` next to its `<base>.before`
54
+ sidecar:
37
55
 
38
56
  ```ts
39
- interface CheckpointEntry {
40
- callId: string // the tool call that mutated the file
41
- anchorSeq: number // seq of the user message anchoring the turn of the change
42
- path: string // resolved display path (absolute)
43
- before: string | null // full content before the change; null = file was created
44
- time: number // epoch ms, strictly increasing within a store instance
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
- - **`before` is the pre-edit state**: `null` means the call created the file.
51
- - **`anchorSeq` ties the backup to a user message**: rewinding to message N
52
- applies every entry anchored at or after N (the boundary is inclusive).
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 full copy: the entry
64
- carries a `ref` (the `<anchorSeq>/<callId>.json` of that prior entry) and omits
65
- `before`, so identical content is never duplicated across entries. A reader
66
- resolves the `ref` back to the terminal real snapshot; `before: null` still
67
- means "the file was created". A `ref` is validated as a single-level,
68
- `<digits>/<callId>.json` relative reference (no traversal) so a corrupt or
69
- hostile ref cannot escape the store root when followed. Because links reference
70
- prior entries, `prune` materializes a surviving link whose `ref` lands on a
71
- group it is about to drop before deleting that group, so no kept link is left
72
- dangling.
73
-
74
- Real entries (with `before`) are unchanged and read identically before and
75
- after this addition; a link entry is a distinct kind that lacks `before`.
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: 1
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 `before` back; delete = unlink
98
- before: string | null // target content for restore; null for delete
99
- rescue: string | null // pre-restore disk state; null = file was absent
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. A restart never rewrites the journal — a
108
- reconciliation *reports* a still non-terminal op as `interrupted` (or
109
- `recovery-required` when the journal is corrupt or a rollback could not
110
- complete), while the journal itself stays `running` / `rollback-running`.
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 persisted atomically BEFORE the first fs
121
- mutation; each action is marked `done` as it is applied.
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`). Non-terminal and
130
- corrupt journals are always kept. Across sessions, `pruneStale` removes whole
131
- long-inactive session directories whose newest member stamp is older than a
132
- configurable idle cutoff (default off), so the store root does not grow
133
- without bound either.
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 malformed entry is read as `undefined` (silently
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 journal schema is `version: 1` (currently descriptive `isRestoreJournal`
150
- validates shape, not version). Checkpoint entries carry no version field. A
151
- future incompatible format should either make readers reject an unknown
152
- `version` (there is no best-effort fallback or legacy coercion) or move the
153
- state root (e.g. `rewind-snapshots/v2`) and ship an explicit migration tool.
154
- Old-format data is never silently re-interpreted.
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
- Automatic cleanup checks at most **once per run** (a restart lets it check again),
54
- on the first session activity (a message you send or a tool call that finishes),
55
- and only when it's enabled and at least 24 hours have passed since the last
56
- check. The 24-hour clock is saved to `<dsh home>/snapshot-cleanup-last-sweep.json`,
57
- so restarting doesn't reset it. It runs in the background and never blocks
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** (the file backups) are ever removed. Your
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
- 自动清理每次运行(宿主启动)**最多检查一次**,发生在首次活动时(你发出一条消息或某次工具调用完成),且仅在**已开启**且距上次检查**已满 24 小时**时才执行。这个 24 小时时钟会写入 `<dsh home>/snapshot-cleanup-last-sweep.json`,重启不会重置。它后台运行,从不阻塞你在做的事。
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
- const result = await session.command(`/rewind preview @${seq} both`);
667
- if (!result.ok || result.value?.matched !== true) return null;
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 if (outcome !== null && outcome.kind === "error") {
916
- bothState = { state: "error", message: outcome.text ?? "unknown error" };
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: "hasChanges" };
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 || result.value?.matched !== true) {
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 = chat === void 0 ? [] : collectTargets(chat, hiddenSeqs);
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.11.0" : "dev";
1349
- var BUILD_HASH = true ? "d770983e" : "dev";
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
  };