dsh-rewind-plugin 0.1.10 → 0.2.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/lib/index.js CHANGED
@@ -2,101 +2,6 @@
2
2
  import { createAssistantMessage } from "@deepseek-ai/dsh-llm";
3
3
  import { unlink } from "node:fs/promises";
4
4
 
5
- // src/session-cwd.ts
6
- import { canonicalPath } from "@deepseek-ai/dsh-sandbox";
7
- var PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
8
- function sessionCwd(cwd, requestedPath) {
9
- if (cwd === void 0 || !PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath)) return cwd;
10
- return canonicalPath(cwd);
11
- }
12
- function execSessionCwd(exec, requestedPath) {
13
- return sessionCwd(exec.agent?.session.header.cwd, requestedPath);
14
- }
15
-
16
- // src/ledger.ts
17
- var MAX_LEDGER_ENTRIES = 2e3;
18
- var RewindLedger = class {
19
- entries = [];
20
- /** Record one committed mutation, dropping the oldest entry when over the cap. */
21
- record(entry) {
22
- this.entries.push(entry);
23
- if (this.entries.length > MAX_LEDGER_ENTRIES) this.entries.shift();
24
- }
25
- /**
26
- * All entries anchored at or after `targetSeq`, newest first. The boundary
27
- * is inclusive: rewinding to a message also reverts the changes its own
28
- * turn caused (the rewind cut removes that turn's assistant response and
29
- * tool calls), so only changes anchored at earlier messages survive.
30
- */
31
- changesAfter(targetSeq) {
32
- const after = [];
33
- for (let i = this.entries.length - 1; i >= 0; i--) {
34
- const entry = this.entries[i];
35
- if (entry.anchorSeq >= targetSeq) after.push(entry);
36
- }
37
- return after;
38
- }
39
- /**
40
- * Unique per-file impact for preview. A file whose earliest affected change
41
- * created it (`before === undefined`) is deleted on restore; any other file
42
- * is written back to its pre-target content.
43
- */
44
- impactsAfter(targetSeq) {
45
- const byPath = /* @__PURE__ */ new Map();
46
- for (const entry of this.entries) {
47
- if (entry.anchorSeq < targetSeq) continue;
48
- if (byPath.has(entry.path)) continue;
49
- byPath.set(entry.path, { path: entry.path, action: entry.before === void 0 ? "delete" : "restore" });
50
- }
51
- return [...byPath.values()];
52
- }
53
- /**
54
- * Reverse every change anchored at or after `targetSeq`. Each entry writes
55
- * its pre-change content back; a file that did not exist before the target
56
- * is deleted instead. Failures are collected per file and never abort the pass.
57
- * @param fs - the filesystem service (resolve/readText/writeText/processPath).
58
- * @param deleteFile - backend-appropriate file deletion by process path.
59
- * @param targetSeq - the rewind target; only later changes are reverted.
60
- * @param options - session workspace cwd (relative ledger paths resolve
61
- * against it, mirroring the fs tools) and an optional abort signal.
62
- */
63
- async restoreAfter(fs, deleteFile, targetSeq, options = {}) {
64
- const restored = [];
65
- const deleted = [];
66
- const failed = [];
67
- const restoredSet = /* @__PURE__ */ new Set();
68
- const deletedSet = /* @__PURE__ */ new Set();
69
- for (const entry of this.changesAfter(targetSeq)) {
70
- try {
71
- const cwd = sessionCwd(options.cwd, entry.path);
72
- const target = await fs.resolve(entry.path, {
73
- ...cwd !== void 0 ? { cwd } : {},
74
- signal: options.signal
75
- });
76
- if (entry.before === void 0) {
77
- await deleteFile(fs.processPath(target));
78
- if (!deletedSet.has(entry.path)) {
79
- deletedSet.add(entry.path);
80
- deleted.push(entry.path);
81
- }
82
- } else {
83
- await fs.writeText(target, entry.before, void 0, options.signal);
84
- if (!restoredSet.has(entry.path)) {
85
- restoredSet.add(entry.path);
86
- restored.push(entry.path);
87
- }
88
- }
89
- } catch (error) {
90
- failed.push({
91
- path: entry.path,
92
- message: error instanceof Error ? error.message : String(error)
93
- });
94
- }
95
- }
96
- return { restored, deleted, failed };
97
- }
98
- };
99
-
100
5
  // src/rewind.ts
101
6
  var RewindError = class extends Error {
102
7
  constructor(code, message) {
@@ -179,6 +84,198 @@ function planRewind(events, surface, target) {
179
84
  };
180
85
  }
181
86
 
87
+ // src/session-cwd.ts
88
+ import { canonicalPath } from "@deepseek-ai/dsh-sandbox";
89
+ var PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
90
+ function sessionCwd(cwd, requestedPath) {
91
+ if (cwd === void 0 || !PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath)) return cwd;
92
+ return canonicalPath(cwd);
93
+ }
94
+ function execSessionCwd(exec, requestedPath) {
95
+ return sessionCwd(exec.agent?.session.header.cwd, requestedPath);
96
+ }
97
+
98
+ // src/snapshot.ts
99
+ import { lstat, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
100
+ import { dirname, join } from "node:path";
101
+ import { homedir } from "node:os";
102
+ var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
103
+ var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
104
+ var MAX_ANCHOR_GROUPS = 100;
105
+ function safeFileId(callId) {
106
+ return callId.replace(/[^a-zA-Z0-9._-]/g, "_");
107
+ }
108
+ function safeSessionId(sessionId) {
109
+ const safe = sessionId.replace(/[^a-zA-Z0-9._-]/g, "_");
110
+ return safe === ".." || safe === "." ? "session" : safe;
111
+ }
112
+ async function readEntry(file) {
113
+ try {
114
+ const parsed = JSON.parse(await readFile(file, "utf8"));
115
+ if (typeof parsed.path !== "string" || typeof parsed.anchorSeq !== "number") return void 0;
116
+ return {
117
+ callId: String(parsed.callId ?? ""),
118
+ anchorSeq: parsed.anchorSeq,
119
+ path: parsed.path,
120
+ before: typeof parsed.before === "string" ? parsed.before : null,
121
+ time: typeof parsed.time === "number" ? parsed.time : 0
122
+ };
123
+ } catch {
124
+ return void 0;
125
+ }
126
+ }
127
+ async function isLinkPath(path) {
128
+ try {
129
+ const stat2 = await lstat(path);
130
+ return stat2.isSymbolicLink() || stat2.nlink > 1;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+ var SnapshotStore = class _SnapshotStore {
136
+ constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT) {
137
+ this.root = root;
138
+ }
139
+ root;
140
+ /** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
141
+ static PRUNE_INTERVAL_MS = 1e3;
142
+ lastPruneAt = 0;
143
+ /** Absolute path of one session's snapshot directory (id sanitized). */
144
+ sessionDir(sessionId) {
145
+ return join(this.root, safeSessionId(sessionId));
146
+ }
147
+ /** Absolute path of one anchor group directory. */
148
+ anchorDir(sessionId, anchorSeq) {
149
+ return join(this.sessionDir(sessionId), String(anchorSeq));
150
+ }
151
+ /** Commit one before-backup under its turn's anchor group. */
152
+ async recordEntry(sessionId, entry) {
153
+ const dir = this.anchorDir(sessionId, entry.anchorSeq);
154
+ await mkdir(dir, { recursive: true });
155
+ const committed = { ...entry, time: Date.now() };
156
+ await writeFile(join(dir, `${safeFileId(entry.callId)}.json`), JSON.stringify(committed), "utf8");
157
+ const now = Date.now();
158
+ if (now - this.lastPruneAt >= _SnapshotStore.PRUNE_INTERVAL_MS) {
159
+ this.lastPruneAt = now;
160
+ await this.prune(sessionId);
161
+ }
162
+ }
163
+ /**
164
+ * All committed entries anchored at or after `targetSeq`, newest first (for
165
+ * preview ordering). The boundary is inclusive: rewinding to a message also
166
+ * reverts the changes its own turn caused (the rewind cut removes that
167
+ * turn's assistant response and tool calls), so only entries anchored at
168
+ * earlier messages survive.
169
+ */
170
+ async entriesAfter(sessionId, targetSeq) {
171
+ const sessionDir = this.sessionDir(sessionId);
172
+ let names;
173
+ try {
174
+ names = await readdir(sessionDir);
175
+ } catch (error) {
176
+ if (error.code === "ENOENT") return [];
177
+ throw error;
178
+ }
179
+ const entries = [];
180
+ for (const name2 of names) {
181
+ const anchorSeq = Number(name2);
182
+ if (!Number.isSafeInteger(anchorSeq) || anchorSeq < targetSeq) continue;
183
+ const files = await readdir(this.anchorDir(sessionId, anchorSeq)).catch(() => []);
184
+ for (const file of files) {
185
+ if (!file.endsWith(".json")) continue;
186
+ const entry = await readEntry(join(this.anchorDir(sessionId, anchorSeq), file));
187
+ if (entry !== void 0) entries.push(entry);
188
+ }
189
+ }
190
+ return entries.sort((a, b) => b.anchorSeq - a.anchorSeq || b.time - a.time);
191
+ }
192
+ /**
193
+ * Per-path EARLIEST committed entry anchored at or after the target — the
194
+ * single source of truth for both restore and impact preview.
195
+ */
196
+ async earliestEntries(sessionId, targetSeq) {
197
+ const earliest = /* @__PURE__ */ new Map();
198
+ for (const entry of await this.entriesAfter(sessionId, targetSeq)) {
199
+ const current = earliest.get(entry.path);
200
+ if (current === void 0 || entry.anchorSeq < current.anchorSeq || entry.anchorSeq === current.anchorSeq && entry.time < current.time) {
201
+ earliest.set(entry.path, entry);
202
+ }
203
+ }
204
+ return earliest;
205
+ }
206
+ /** Per-file restore impact for the earliest entry at/after the target. */
207
+ async impactsAfter(sessionId, targetSeq) {
208
+ return [...(await this.earliestEntries(sessionId, targetSeq)).values()].sort((a, b) => a.path.localeCompare(b.path)).map((entry) => ({
209
+ path: entry.path,
210
+ action: entry.before === null ? "delete" : "restore"
211
+ }));
212
+ }
213
+ /**
214
+ * Restore the workspace to the target message's checkpoint: for every path
215
+ * with entries anchored at or after it, apply the EARLIEST entry — write the
216
+ * before content back, or delete the file when it was created after the
217
+ * target. Symlinked and hard-linked paths are skipped (reported, never
218
+ * written through); a restored file's parent directory is created when it
219
+ * was deleted after the backup. Failures are per-file and never abort the
220
+ * pass.
221
+ */
222
+ async restoreAfter(sessionId, targetSeq, deleteFile) {
223
+ const restored = [];
224
+ const deleted = [];
225
+ const skipped = [];
226
+ const failed = [];
227
+ for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
228
+ try {
229
+ if (await isLinkPath(entry.path)) {
230
+ skipped.push(entry.path);
231
+ continue;
232
+ }
233
+ if (entry.before === null) {
234
+ await deleteFile(entry.path);
235
+ deleted.push(entry.path);
236
+ } else {
237
+ await mkdir(dirname(entry.path), { recursive: true });
238
+ await writeFile(entry.path, entry.before, "utf8");
239
+ restored.push(entry.path);
240
+ }
241
+ } catch (error) {
242
+ failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
243
+ }
244
+ }
245
+ return { restored, deleted, skipped, failed };
246
+ }
247
+ /**
248
+ * Drop the session's oldest anchor groups beyond `keep` (default
249
+ * {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
250
+ */
251
+ async prune(sessionId, keep = MAX_ANCHOR_GROUPS) {
252
+ const sessionDir = this.sessionDir(sessionId);
253
+ let names;
254
+ try {
255
+ names = await readdir(sessionDir);
256
+ } catch (error) {
257
+ if (error.code === "ENOENT") return;
258
+ throw error;
259
+ }
260
+ const seqs = names.map(Number).filter((seq) => Number.isSafeInteger(seq)).sort((a, b) => a - b);
261
+ const excess = seqs.length - keep;
262
+ if (excess <= 0) return;
263
+ for (const seq of seqs.slice(0, excess)) {
264
+ await rm(this.anchorDir(sessionId, seq), { recursive: true, force: true });
265
+ }
266
+ }
267
+ /** True when a path exists on disk (used by tests and diagnostics). */
268
+ async exists(path) {
269
+ try {
270
+ await stat(path);
271
+ return true;
272
+ } catch (error) {
273
+ if (error.code === "ENOENT") return false;
274
+ throw error;
275
+ }
276
+ }
277
+ };
278
+
182
279
  // src/index.ts
183
280
  var name = "dsh-rewind";
184
281
  var inject = ["commands", "tools"];
@@ -186,8 +283,9 @@ var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_edito
186
283
  var MUTATING_EDITOR_COMMANDS = /* @__PURE__ */ new Set(["create", "str_replace", "insert"]);
187
284
  var USAGE = [
188
285
  "Usage:",
189
- " /rewind \u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F\uFF08\u4E0D\u63A5\u53D7\u53C2\u6570\uFF09",
190
- " \u56DE\u9000\u5230\u66F4\u65E9\u7684\u6D88\u606F\u8BF7\u4F7F\u7528\u8BE5\u6D88\u606F\u65C1\u7684\u300C\u56DE\u9000\u300D\u6309\u94AE"
286
+ " /rewind \uFF08\u65E0\u53C2\u6570\uFF09\u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F",
287
+ " /rewind @<seq> chat|both \u56DE\u9000\u5230\u6307\u5B9A\u6D88\u606F\uFF08chat \u4EC5\u5BF9\u8BDD / both \u5BF9\u8BDD+\u6587\u4EF6\uFF09",
288
+ " \u624B\u52A8\u8F93\u5165 /rewind \u4F1A\u88AB\u62E6\u622A\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684\u300C\u56DE\u9000\u300D\u6309\u94AE"
191
289
  ].join("\n");
192
290
  function mutationPathOf(exec) {
193
291
  const args = exec.arguments;
@@ -200,12 +298,19 @@ function mutationPathOf(exec) {
200
298
  }
201
299
  return void 0;
202
300
  }
203
- function anchorSeqOf(session) {
204
- for (let i = session.events.length - 1; i >= 0; i--) {
205
- const event = session.events[i];
206
- if (event.type === "user/message") return event.seq;
301
+ function anchorSeqOf(session, cache) {
302
+ const events = session.events;
303
+ const cached = cache.get(session.id);
304
+ if (cached !== void 0 && cached.eventsLength === events.length) return cached.anchor;
305
+ let anchor = cached?.anchor;
306
+ for (let i = events.length - 1; i >= (cached?.eventsLength ?? 0); i--) {
307
+ if (events[i].type === "user/message") {
308
+ anchor = events[i].seq;
309
+ break;
310
+ }
207
311
  }
208
- return void 0;
312
+ cache.set(session.id, { anchor, eventsLength: events.length });
313
+ return anchor;
209
314
  }
210
315
  async function resolveTarget(fs, path, cwd, signal) {
211
316
  try {
@@ -234,9 +339,9 @@ async function captureBefore(fs, exec, pending) {
234
339
  const target = await resolveTarget(fs, path, cwd, exec.signal);
235
340
  if (target === void 0) return;
236
341
  const before = await readTextOrUndefined(fs, target, exec.signal);
237
- pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, cwd, before });
342
+ pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, before });
238
343
  }
239
- async function commitEntry(fs, ledgerFor, pending, exec, result) {
344
+ async function commitEntry(store, pending, anchorCache, exec, result) {
240
345
  const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
241
346
  const capture = pending.get(key);
242
347
  if (capture === void 0) return;
@@ -244,22 +349,13 @@ async function commitEntry(fs, ledgerFor, pending, exec, result) {
244
349
  if (result.isError) return;
245
350
  const agent = exec.agent;
246
351
  if (agent === void 0) return;
247
- const anchorSeq = anchorSeqOf(agent.session);
352
+ const anchorSeq = anchorSeqOf(agent.session, anchorCache);
248
353
  if (anchorSeq === void 0) return;
249
- const target = await resolveTarget(fs, capture.path, capture.cwd, exec.signal);
250
- if (target === void 0) return;
251
- let after;
252
- try {
253
- after = await readTextOrUndefined(fs, target, exec.signal) ?? "";
254
- } catch {
255
- return;
256
- }
257
- ledgerFor(agent.session).record({
258
- toolName: exec.name,
354
+ await store.recordEntry(agent.session.id, {
355
+ callId: exec.callId,
259
356
  anchorSeq,
260
357
  path: capture.path,
261
- before: capture.before,
262
- after
358
+ before: capture.before ?? null
263
359
  });
264
360
  }
265
361
  function buildMarker() {
@@ -290,7 +386,7 @@ function formatPlan(plan, files) {
290
386
  lines.push(` ${file.action === "restore" ? "\u8FD8\u539F" : "\u5220\u9664"} ${file.path}`);
291
387
  }
292
388
  } else {
293
- lines.push("\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53F0\u8D26\u8BB0\u5F55\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002");
389
+ lines.push("\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u5FEB\u7167\u8BB0\u5F55\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002");
294
390
  }
295
391
  return lines.join("\n");
296
392
  }
@@ -313,7 +409,7 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
313
409
  }
314
410
  return true;
315
411
  }
316
- async function executeRewind(ctx, ledger, invocation, rawTarget, mode) {
412
+ async function executeRewind(ctx, store, invocation, rawTarget, mode) {
317
413
  const { agent } = invocation;
318
414
  if (agent.status !== "idle") {
319
415
  agent.cancel({ kind: "user" });
@@ -343,18 +439,13 @@ async function executeRewind(ctx, ledger, invocation, rawTarget, mode) {
343
439
  }
344
440
  let restore = "";
345
441
  if (mode === "both") {
346
- const fs = ctx.get("fs", false);
347
- if (fs === void 0) {
348
- restore = "\uFF1B\u672A\u627E\u5230\u6587\u4EF6\u7CFB\u7EDF\u670D\u52A1\uFF0C\u672A\u8FD8\u539F\u6587\u4EF6\uFF08\u53EF\u4EC5\u7528 chat \u6A21\u5F0F\u56DE\u9000\u5BF9\u8BDD\uFF09";
349
- } else {
350
- const outcome = await ledger.restoreAfter(fs, (processPath) => unlink(processPath), plan.targetSeq, {
351
- // Relative ledger paths resolve against the session workspace, exactly
352
- // as the fs tools resolve them (per-entry in src/ledger.ts).
353
- cwd: agent.session.header.cwd,
354
- signal: invocation.signal
355
- });
356
- restore = `\uFF1B\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6\u3001\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6${renderFailures(outcome.failed)}`;
357
- }
442
+ const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
443
+ const parts = [];
444
+ if (outcome.restored.length > 0) parts.push(`\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6`);
445
+ if (outcome.deleted.length > 0) parts.push(`\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6`);
446
+ if (outcome.skipped.length > 0) parts.push(`\u8DF3\u8FC7 ${outcome.skipped.length} \u4E2A\u94FE\u63A5`);
447
+ restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4";
448
+ restore += renderFailures(outcome.failed);
358
449
  }
359
450
  return {
360
451
  kind: "success",
@@ -374,7 +465,7 @@ function rewindErrorResult(error) {
374
465
  }
375
466
  throw error;
376
467
  }
377
- async function handleRewind(ctx, ledger, invocation) {
468
+ async function handleRewind(ctx, store, invocation) {
378
469
  const session = invocation.agent.session;
379
470
  const input = invocation.rawInput.trim();
380
471
  if (input === "") {
@@ -382,7 +473,7 @@ async function handleRewind(ctx, ledger, invocation) {
382
473
  if (candidates.length === 0) {
383
474
  return { kind: "error", text: "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002" };
384
475
  }
385
- return executeRewind(ctx, ledger, invocation, `@${candidates[0].seq}`, "chat");
476
+ return executeRewind(ctx, store, invocation, `@${candidates[0].seq}`, "chat");
386
477
  }
387
478
  const parts = input.split(/\s+/);
388
479
  if (parts[0] === "preview") {
@@ -394,7 +485,7 @@ async function handleRewind(ctx, ledger, invocation) {
394
485
  } catch (error) {
395
486
  return rewindErrorResult(error);
396
487
  }
397
- const impacts = ledger.impactsAfter(plan.targetSeq);
488
+ const impacts = await store.impactsAfter(session.id, plan.targetSeq);
398
489
  return { kind: "success", text: formatPlan(plan, impacts) };
399
490
  }
400
491
  const target = parts[0];
@@ -412,24 +503,17 @@ async function handleRewind(ctx, ledger, invocation) {
412
503
  /rewind ${target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6`
413
504
  };
414
505
  }
415
- return executeRewind(ctx, ledger, invocation, target, mode);
506
+ return executeRewind(ctx, store, invocation, target, mode);
416
507
  }
417
- function apply(ctx) {
418
- const ledgers = /* @__PURE__ */ new Map();
419
- const ledgerFor = (session) => {
420
- let ledger = ledgers.get(session.id);
421
- if (ledger === void 0) {
422
- ledger = new RewindLedger();
423
- ledgers.set(session.id, ledger);
424
- }
425
- return ledger;
426
- };
508
+ function apply(ctx, config) {
509
+ const store = new SnapshotStore(config?.snapshotDir);
427
510
  const pending = /* @__PURE__ */ new Map();
511
+ const anchorCache = /* @__PURE__ */ new Map();
428
512
  ctx.effect(function* () {
429
513
  yield ctx.commands.register({
430
514
  name: "rewind",
431
515
  description: "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09",
432
- handler: (invocation) => handleRewind(ctx, ledgerFor(invocation.agent.session), invocation)
516
+ handler: (invocation) => handleRewind(ctx, store, invocation)
433
517
  });
434
518
  }, "dsh-rewind command");
435
519
  ctx.inject(["fs"], (scope) => {
@@ -444,15 +528,20 @@ function apply(ctx) {
444
528
  });
445
529
  scope.on("tools/post-execute", async (exec, result, next) => {
446
530
  try {
447
- await commitEntry(fs, ledgerFor, pending, exec, result);
531
+ await commitEntry(store, pending, anchorCache, exec, result);
448
532
  } catch (error) {
449
- ctx.logger.warn(`[dsh-rewind] ledger commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
533
+ ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
450
534
  }
451
535
  return next();
452
536
  });
537
+ scope.on("tools/result", (exec) => {
538
+ pending.delete(`${exec.agent?.id ?? "anon"}:${exec.callId}`);
539
+ return void 0;
540
+ });
453
541
  });
454
542
  }
455
543
  export {
544
+ SnapshotStore,
456
545
  apply,
457
546
  inject,
458
547
  name
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Pure computation of the chat rows a rewind hides from the rendered
3
+ * transcript. Extracted from the client plugin (`src/client/index.ts`) so the
4
+ * multi-rewind cut logic stays unit-testable without a DOM.
5
+ *
6
+ * @module dsh-rewind/client/hidden
7
+ */
8
+ import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-runtime/client';
9
+ /** Minimal chat snapshot reader the hiding logic needs. */
10
+ export interface HiddenChat {
11
+ readonly order: readonly string[];
12
+ readonly nodes: {
13
+ get(key: string): ChatConversationViewNode | undefined;
14
+ };
15
+ }
16
+ /** Extract the rewind target from a command outcome text ("已撤回 seq N..."). */
17
+ export declare function targetOfOutcome(text: string | undefined): number | undefined;
18
+ /**
19
+ * Anchor seqs that must be hidden from the rendered transcript so the user
20
+ * sees the conversation as the agent sees it: every EXECUTED `/rewind`
21
+ * command row (one that appended a marker; preview-only rows stay visible
22
+ * until a real rewind's range covers them) and every message withdrawn by a
23
+ * rewind — the target message itself, everything after it, and the (empty,
24
+ * unrendered) marker.
25
+ *
26
+ * The cut span is [min target, max marker] across ALL executed rewinds, not
27
+ * just the newest one: every rewind withdraws its target and everything after
28
+ * it, and a later rewind to a LATER point (after new traffic) must not re-show
29
+ * rows an earlier rewind already cut. Endpoints come from the command nodes:
30
+ * `sourceEventSeq` is the marker's log seq, and the outcome text carries the
31
+ * target seq.
32
+ */
33
+ export declare function hiddenSeqsOf(snap: HiddenChat): Set<number>;
@@ -16,10 +16,10 @@
16
16
  * before executing. Execution always goes through `session.command(...)`, the
17
17
  * same host path the `/rewind` command uses.
18
18
  *
19
- * Manual composer input of `/rewind` is deliberately restricted (the guard
20
- * below): it takes no parameters a bare `/rewind` withdraws the most recent
21
- * message, and any `/rewind <args>` line is blocked with a hint pointing at
22
- * the per-message button.
19
+ * Manual composer input of `/rewind` is deliberately blocked (the guard
20
+ * below): the command exists only as the per-message button's internal
21
+ * channel, so any `/rewind` line typed by hand — bare or with arguments is
22
+ * stopped with a hint pointing at the button.
23
23
  *
24
24
  * @module dsh-rewind/client
25
25
  */
@@ -4,11 +4,13 @@ export declare const zh: {
4
4
  'button.aria': string;
5
5
  'button.title': string;
6
6
  'popover.title': string;
7
- 'popover.target': string;
7
+ 'popover.noText': string;
8
8
  'popover.chat': string;
9
9
  'popover.chat.hint': string;
10
10
  'popover.both': string;
11
11
  'popover.both.hint': string;
12
+ 'popover.checking': string;
13
+ 'popover.noChanges': string;
12
14
  'popover.cancel': string;
13
15
  'popover.impact.loading': string;
14
16
  'popover.impact.failed': string;
@@ -30,11 +32,13 @@ export declare const en: {
30
32
  'button.aria': string;
31
33
  'button.title': string;
32
34
  'popover.title': string;
33
- 'popover.target': string;
35
+ 'popover.noText': string;
34
36
  'popover.chat': string;
35
37
  'popover.chat.hint': string;
36
38
  'popover.both': string;
37
39
  'popover.both.hint': string;
40
+ 'popover.checking': string;
41
+ 'popover.noChanges': string;
38
42
  'popover.cancel': string;
39
43
  'popover.impact.loading': string;
40
44
  'popover.impact.failed': string;
@@ -25,4 +25,4 @@ export declare const CLASS: {
25
25
  /** The ↶ glyph, drawn inline so the bundle stays dependency-free. */
26
26
  export declare const REWIND_ICON_SVG: string;
27
27
  /** One injected stylesheet (scoped under `.dsh-rewind-*`). */
28
- export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: 28px;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border-radius: 12px;\n background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-accent, var(--dsw-accent, #5b8cff));\n color: var(--dsw-alias-on-accent, #fff);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-rewind-guard-hint {\n position: fixed;\n z-index: 1000;\n max-width: min(440px, calc(100vw - 24px));\n padding: 8px 12px;\n border-radius: 10px;\n background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n";
28
+ export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: 28px;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 12px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-option:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n.dsh-rewind-popover-primary:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-rewind-guard-hint {\n position: fixed;\n z-index: 1000;\n max-width: min(440px, calc(100vw - 24px));\n padding: 8px 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n";