dsh-rewind-plugin 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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) {
@@ -178,13 +83,181 @@ function planRewind(events, surface, target) {
178
83
  surfaceEnd: shadowedSeqs[shadowedSeqs.length - 1]
179
84
  };
180
85
  }
181
- function formatCandidate(candidate) {
182
- const time = new Date(candidate.time);
183
- const hh = String(time.getHours()).padStart(2, "0");
184
- const mm = String(time.getMinutes()).padStart(2, "0");
185
- return `${candidate.index}. ${hh}:${mm} ${candidate.preview || "(\u65E0\u6587\u672C)"}`;
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);
186
96
  }
187
97
 
98
+ // src/snapshot.ts
99
+ import { mkdir, readFile, readdir, rm, writeFile, lstat } from "node:fs/promises";
100
+ import { 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
+ async function readEntry(file) {
109
+ try {
110
+ const parsed = JSON.parse(await readFile(file, "utf8"));
111
+ if (typeof parsed.path !== "string" || typeof parsed.anchorSeq !== "number") return void 0;
112
+ return {
113
+ callId: String(parsed.callId ?? ""),
114
+ anchorSeq: parsed.anchorSeq,
115
+ path: parsed.path,
116
+ before: typeof parsed.before === "string" ? parsed.before : null,
117
+ time: typeof parsed.time === "number" ? parsed.time : 0
118
+ };
119
+ } catch {
120
+ return void 0;
121
+ }
122
+ }
123
+ async function isSymbolicLink(path) {
124
+ try {
125
+ return (await lstat(path)).isSymbolicLink();
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+ var SnapshotStore = class {
131
+ constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT) {
132
+ this.root = root;
133
+ }
134
+ root;
135
+ /** Absolute path of one anchor group directory. */
136
+ anchorDir(sessionId, anchorSeq) {
137
+ return join(this.root, sessionId, String(anchorSeq));
138
+ }
139
+ /** Commit one before-backup under its turn's anchor group. */
140
+ async recordEntry(sessionId, entry) {
141
+ const dir = this.anchorDir(sessionId, entry.anchorSeq);
142
+ await mkdir(dir, { recursive: true });
143
+ const committed = { ...entry, time: Date.now() };
144
+ await writeFile(join(dir, `${safeFileId(entry.callId)}.json`), JSON.stringify(committed), "utf8");
145
+ await this.prune(sessionId);
146
+ }
147
+ /**
148
+ * All committed entries anchored at or after `targetSeq`, newest first (for
149
+ * preview ordering). The boundary is inclusive: rewinding to a message also
150
+ * reverts the changes its own turn caused (the rewind cut removes that
151
+ * turn's assistant response and tool calls), so only entries anchored at
152
+ * earlier messages survive.
153
+ */
154
+ async entriesAfter(sessionId, targetSeq) {
155
+ const sessionDir = join(this.root, sessionId);
156
+ let names;
157
+ try {
158
+ names = await readdir(sessionDir);
159
+ } catch (error) {
160
+ if (error.code === "ENOENT") return [];
161
+ throw error;
162
+ }
163
+ const entries = [];
164
+ for (const name2 of names) {
165
+ const anchorSeq = Number(name2);
166
+ if (!Number.isSafeInteger(anchorSeq) || anchorSeq < targetSeq) continue;
167
+ const files = await readdir(this.anchorDir(sessionId, anchorSeq)).catch(() => []);
168
+ for (const file of files) {
169
+ if (!file.endsWith(".json")) continue;
170
+ const entry = await readEntry(join(this.anchorDir(sessionId, anchorSeq), file));
171
+ if (entry !== void 0) entries.push(entry);
172
+ }
173
+ }
174
+ return entries.sort((a, b) => b.anchorSeq - a.anchorSeq || b.time - a.time);
175
+ }
176
+ /** Per-file restore impact for the earliest entry at/after the target. */
177
+ async impactsAfter(sessionId, targetSeq) {
178
+ const earliest = /* @__PURE__ */ new Map();
179
+ for (const entry of await this.entriesAfter(sessionId, targetSeq)) {
180
+ const current = earliest.get(entry.path);
181
+ if (current === void 0 || entry.anchorSeq < current.anchorSeq || entry.anchorSeq === current.anchorSeq && entry.time < current.time) {
182
+ earliest.set(entry.path, entry);
183
+ }
184
+ }
185
+ return [...earliest.values()].sort((a, b) => a.path.localeCompare(b.path)).map((entry) => ({
186
+ path: entry.path,
187
+ action: entry.before === null ? "delete" : "restore"
188
+ }));
189
+ }
190
+ /**
191
+ * Restore the workspace to the target message's checkpoint: for every path
192
+ * with entries anchored at or after it, apply the EARLIEST entry — write the
193
+ * before content back, or delete the file when it was created after the
194
+ * target. Symbolic links are skipped (reported, never written through).
195
+ * Failures are per-file and never abort the pass.
196
+ */
197
+ async restoreAfter(sessionId, targetSeq, deleteFile) {
198
+ const restored = [];
199
+ const deleted = [];
200
+ const skipped = [];
201
+ const failed = [];
202
+ const earliest = /* @__PURE__ */ new Map();
203
+ for (const entry of await this.entriesAfter(sessionId, targetSeq)) {
204
+ const current = earliest.get(entry.path);
205
+ if (current === void 0 || entry.anchorSeq < current.anchorSeq || entry.anchorSeq === current.anchorSeq && entry.time < current.time) {
206
+ earliest.set(entry.path, entry);
207
+ }
208
+ }
209
+ for (const entry of earliest.values()) {
210
+ try {
211
+ if (await isSymbolicLink(entry.path)) {
212
+ skipped.push(entry.path);
213
+ continue;
214
+ }
215
+ if (entry.before === null) {
216
+ await deleteFile(entry.path);
217
+ deleted.push(entry.path);
218
+ } else {
219
+ await writeFile(entry.path, entry.before, "utf8");
220
+ restored.push(entry.path);
221
+ }
222
+ } catch (error) {
223
+ failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
224
+ }
225
+ }
226
+ return { restored, deleted, skipped, failed };
227
+ }
228
+ /**
229
+ * Drop the session's oldest anchor groups beyond `keep` (default
230
+ * {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
231
+ */
232
+ async prune(sessionId, keep = MAX_ANCHOR_GROUPS) {
233
+ const sessionDir = join(this.root, sessionId);
234
+ let names;
235
+ try {
236
+ names = await readdir(sessionDir);
237
+ } catch (error) {
238
+ if (error.code === "ENOENT") return;
239
+ throw error;
240
+ }
241
+ const seqs = names.map(Number).filter((seq) => Number.isSafeInteger(seq)).sort((a, b) => a - b);
242
+ const excess = seqs.length - keep;
243
+ if (excess <= 0) return;
244
+ for (const seq of seqs.slice(0, excess)) {
245
+ await rm(this.anchorDir(sessionId, seq), { recursive: true, force: true });
246
+ }
247
+ }
248
+ /** True when a path exists on disk (used by tests and diagnostics). */
249
+ async exists(path) {
250
+ const { stat } = await import("node:fs/promises");
251
+ try {
252
+ await stat(path);
253
+ return true;
254
+ } catch (error) {
255
+ if (error.code === "ENOENT") return false;
256
+ throw error;
257
+ }
258
+ }
259
+ };
260
+
188
261
  // src/index.ts
189
262
  var name = "dsh-rewind";
190
263
  var inject = ["commands", "tools"];
@@ -192,11 +265,8 @@ var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_edito
192
265
  var MUTATING_EDITOR_COMMANDS = /* @__PURE__ */ new Set(["create", "str_replace", "insert"]);
193
266
  var USAGE = [
194
267
  "Usage:",
195
- " /rewind list recent user messages to rewind to",
196
- " /rewind <\u5E8F\u53F7|@seq> choose a mode for that message",
197
- " /rewind <\u5E8F\u53F7|@seq> chat rewind the conversation only",
198
- " /rewind <\u5E8F\u53F7|@seq> both rewind conversation and restore files",
199
- " /rewind preview <\u76EE\u6807> show the impact list without executing"
268
+ " /rewind \u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F\uFF08\u4E0D\u63A5\u53D7\u53C2\u6570\uFF09",
269
+ " \u56DE\u9000\u5230\u66F4\u65E9\u7684\u6D88\u606F\u8BF7\u4F7F\u7528\u8BE5\u6D88\u606F\u65C1\u7684\u300C\u56DE\u9000\u300D\u6309\u94AE"
200
270
  ].join("\n");
201
271
  function mutationPathOf(exec) {
202
272
  const args = exec.arguments;
@@ -243,9 +313,9 @@ async function captureBefore(fs, exec, pending) {
243
313
  const target = await resolveTarget(fs, path, cwd, exec.signal);
244
314
  if (target === void 0) return;
245
315
  const before = await readTextOrUndefined(fs, target, exec.signal);
246
- pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, cwd, before });
316
+ pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, before });
247
317
  }
248
- async function commitEntry(fs, ledgerFor, pending, exec, result) {
318
+ async function commitEntry(store, pending, exec, result) {
249
319
  const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
250
320
  const capture = pending.get(key);
251
321
  if (capture === void 0) return;
@@ -255,20 +325,11 @@ async function commitEntry(fs, ledgerFor, pending, exec, result) {
255
325
  if (agent === void 0) return;
256
326
  const anchorSeq = anchorSeqOf(agent.session);
257
327
  if (anchorSeq === void 0) return;
258
- const target = await resolveTarget(fs, capture.path, capture.cwd, exec.signal);
259
- if (target === void 0) return;
260
- let after;
261
- try {
262
- after = await readTextOrUndefined(fs, target, exec.signal) ?? "";
263
- } catch {
264
- return;
265
- }
266
- ledgerFor(agent.session).record({
267
- toolName: exec.name,
328
+ await store.recordEntry(agent.session.id, {
329
+ callId: exec.callId,
268
330
  anchorSeq,
269
331
  path: capture.path,
270
- before: capture.before,
271
- after
332
+ before: capture.before ?? null
272
333
  });
273
334
  }
274
335
  function buildMarker() {
@@ -299,7 +360,7 @@ function formatPlan(plan, files) {
299
360
  lines.push(` ${file.action === "restore" ? "\u8FD8\u539F" : "\u5220\u9664"} ${file.path}`);
300
361
  }
301
362
  } else {
302
- 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");
363
+ 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");
303
364
  }
304
365
  return lines.join("\n");
305
366
  }
@@ -322,7 +383,7 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
322
383
  }
323
384
  return true;
324
385
  }
325
- async function executeRewind(ctx, ledger, invocation, rawTarget, mode) {
386
+ async function executeRewind(ctx, store, invocation, rawTarget, mode) {
326
387
  const { agent } = invocation;
327
388
  if (agent.status !== "idle") {
328
389
  agent.cancel({ kind: "user" });
@@ -352,18 +413,13 @@ async function executeRewind(ctx, ledger, invocation, rawTarget, mode) {
352
413
  }
353
414
  let restore = "";
354
415
  if (mode === "both") {
355
- const fs = ctx.get("fs", false);
356
- if (fs === void 0) {
357
- 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";
358
- } else {
359
- const outcome = await ledger.restoreAfter(fs, (processPath) => unlink(processPath), plan.targetSeq, {
360
- // Relative ledger paths resolve against the session workspace, exactly
361
- // as the fs tools resolve them (per-entry in src/ledger.ts).
362
- cwd: agent.session.header.cwd,
363
- signal: invocation.signal
364
- });
365
- restore = `\uFF1B\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6\u3001\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6${renderFailures(outcome.failed)}`;
366
- }
416
+ const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
417
+ const parts = [];
418
+ if (outcome.restored.length > 0) parts.push(`\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6`);
419
+ if (outcome.deleted.length > 0) parts.push(`\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6`);
420
+ if (outcome.skipped.length > 0) parts.push(`\u8DF3\u8FC7 ${outcome.skipped.length} \u4E2A\u7B26\u53F7\u94FE\u63A5`);
421
+ restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4";
422
+ restore += renderFailures(outcome.failed);
367
423
  }
368
424
  return {
369
425
  kind: "success",
@@ -383,21 +439,15 @@ function rewindErrorResult(error) {
383
439
  }
384
440
  throw error;
385
441
  }
386
- async function handleRewind(ctx, ledger, invocation) {
442
+ async function handleRewind(ctx, store, invocation) {
387
443
  const session = invocation.agent.session;
388
444
  const input = invocation.rawInput.trim();
389
445
  if (input === "") {
390
- const candidates = listRewindCandidates(session.events, session.surface.nodes);
446
+ const candidates = listRewindCandidates(session.events, session.surface.nodes, 1);
391
447
  if (candidates.length === 0) {
392
448
  return { kind: "error", text: "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002" };
393
449
  }
394
- return {
395
- kind: "success",
396
- text: `\u9009\u62E9\u8981\u56DE\u9000\u5230\u7684\u7528\u6237\u6D88\u606F\uFF08\u6700\u8FD1\u5728\u4E0A\uFF09\uFF1A
397
- ${candidates.map(formatCandidate).join("\n")}
398
-
399
- \u7EE7\u7EED\uFF1A/rewind <\u5E8F\u53F7|@seq>\uFF0C\u6216\u76F4\u63A5 /rewind <\u5E8F\u53F7|@seq> chat|both \u6267\u884C\u3002`
400
- };
450
+ return executeRewind(ctx, store, invocation, `@${candidates[0].seq}`, "chat");
401
451
  }
402
452
  const parts = input.split(/\s+/);
403
453
  if (parts[0] === "preview") {
@@ -409,7 +459,7 @@ ${candidates.map(formatCandidate).join("\n")}
409
459
  } catch (error) {
410
460
  return rewindErrorResult(error);
411
461
  }
412
- const impacts = ledger.impactsAfter(plan.targetSeq);
462
+ const impacts = await store.impactsAfter(session.id, plan.targetSeq);
413
463
  return { kind: "success", text: formatPlan(plan, impacts) };
414
464
  }
415
465
  const target = parts[0];
@@ -427,24 +477,16 @@ ${candidates.map(formatCandidate).join("\n")}
427
477
  /rewind ${target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6`
428
478
  };
429
479
  }
430
- return executeRewind(ctx, ledger, invocation, target, mode);
480
+ return executeRewind(ctx, store, invocation, target, mode);
431
481
  }
432
- function apply(ctx) {
433
- const ledgers = /* @__PURE__ */ new Map();
434
- const ledgerFor = (session) => {
435
- let ledger = ledgers.get(session.id);
436
- if (ledger === void 0) {
437
- ledger = new RewindLedger();
438
- ledgers.set(session.id, ledger);
439
- }
440
- return ledger;
441
- };
482
+ function apply(ctx, config) {
483
+ const store = new SnapshotStore(config?.snapshotDir);
442
484
  const pending = /* @__PURE__ */ new Map();
443
485
  ctx.effect(function* () {
444
486
  yield ctx.commands.register({
445
487
  name: "rewind",
446
488
  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",
447
- handler: (invocation) => handleRewind(ctx, ledgerFor(invocation.agent.session), invocation)
489
+ handler: (invocation) => handleRewind(ctx, store, invocation)
448
490
  });
449
491
  }, "dsh-rewind command");
450
492
  ctx.inject(["fs"], (scope) => {
@@ -459,15 +501,16 @@ function apply(ctx) {
459
501
  });
460
502
  scope.on("tools/post-execute", async (exec, result, next) => {
461
503
  try {
462
- await commitEntry(fs, ledgerFor, pending, exec, result);
504
+ await commitEntry(store, pending, exec, result);
463
505
  } catch (error) {
464
- ctx.logger.warn(`[dsh-rewind] ledger commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
506
+ ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
465
507
  }
466
508
  return next();
467
509
  });
468
510
  });
469
511
  }
470
512
  export {
513
+ SnapshotStore,
471
514
  apply,
472
515
  inject,
473
516
  name
@@ -16,6 +16,11 @@
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 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
+ *
19
24
  * @module dsh-rewind/client
20
25
  */
21
26
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
@@ -15,6 +15,7 @@ export declare const zh: {
15
15
  'popover.impact.none': string;
16
16
  'popover.confirm': string;
17
17
  'popover.back': string;
18
+ 'guard.hint': string;
18
19
  };
19
20
  /** The rewind namespace key union. */
20
21
  export type RewindKey = keyof typeof zh;
@@ -40,4 +41,5 @@ export declare const en: {
40
41
  'popover.impact.none': string;
41
42
  'popover.confirm': string;
42
43
  'popover.back': string;
44
+ 'guard.hint': string;
43
45
  };
@@ -20,8 +20,9 @@ export declare const CLASS: {
20
20
  readonly popoverActions: 'dsh-rewind-popover-actions';
21
21
  readonly popoverPrimary: 'dsh-rewind-popover-primary';
22
22
  readonly popoverGhost: 'dsh-rewind-popover-ghost';
23
+ readonly guardHint: 'dsh-rewind-guard-hint';
23
24
  };
24
25
  /** The ↶ glyph, drawn inline so the bundle stays dependency-free. */
25
26
  export declare const REWIND_ICON_SVG: string;
26
27
  /** One injected stylesheet (scoped under `.dsh-rewind-*`). */
27
- 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";
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";
@@ -1,35 +1,58 @@
1
1
  /**
2
- * dsh-rewind host half: the `/rewind` command and the write-class change
3
- * ledger, composed as one dual-face bundle row (the browser half lives in
4
- * `src/client/`).
2
+ * dsh-rewind host half: the `/rewind` command and the Claude-Code-style
3
+ * checkpoint store, composed as one dual-face bundle row (the browser half
4
+ * lives in `src/client/`).
5
5
  *
6
6
  * Rewind mechanism: planning is pure (`src/rewind.ts`); execution appends a
7
- * marker `user/message` into the session log whose `surfaceOp` replaces every
8
- * surface node after the target message with the marker. The append-only log
9
- * (and the rendered transcript) is untouched — only the model-visible surface
10
- * is cut, so the next request derives its context from the target onward.
11
- * Mode `both` additionally reverses every ledger-recorded file change that
12
- * followed the target.
7
+ * marker `assistant/message` into the session log whose `surfaceOp` replaces
8
+ * every surface node after the target message with the marker. The
9
+ * append-only log (and the rendered transcript) is untouched — only the
10
+ * model-visible surface is cut, so the next request derives its context from
11
+ * the target onward.
12
+ *
13
+ * File restore (mode `both`) follows Claude Code's checkpointing: the plugin
14
+ * backs up each tracked write-class edit BEFORE it happens (at the
15
+ * `tools/execute` around-dispatch stage, so an approval short-circuit cannot
16
+ * skip the capture and a denied call never records), commits the backup under
17
+ * the turn's anchor message seq at `tools/post-execute`, and a rewind to
18
+ * message N restores every backup anchored at or after N — modified files are
19
+ * written back to their pre-edit content, files created after N are deleted.
20
+ * Backups persist on disk under the dsh data directory (newest 100 message
21
+ * groups per session), so restores work after a host restart, and they
22
+ * read/write the real file system with plain `node:fs` — independent of the
23
+ * fs service. See `src/snapshot.ts`.
13
24
  *
14
25
  * @module dsh-rewind
15
26
  */
16
27
  import type { Context } from '@deepseek-ai/cordis';
28
+ export { SnapshotStore } from './snapshot.ts';
29
+ export type { CheckpointEntry, FileImpact, RestoreOutcome } from './snapshot.ts';
17
30
  export declare const name = "dsh-rewind";
18
31
  export declare const inject: string[];
32
+ /** Plugin config: optional override of the checkpoint store root. */
33
+ export interface RewindConfig {
34
+ /** Checkpoint store root (defaults to `~/.dsh/rewind-snapshots`). */
35
+ readonly snapshotDir?: string;
36
+ }
19
37
  /**
20
- * Register the `/rewind` command and the tools-pipeline ledger hooks.
38
+ * Register the `/rewind` command and the checkpoint pipeline (before-capture
39
+ * at `tools/execute`, disk commit at `tools/post-execute`).
21
40
  *
22
- * The command is fs-independent and registers immediately. The ledger needs
23
- * `fs`, so its hooks mount through a dynamic `ctx.inject(['fs'])` — they take
24
- * effect whenever the fs service becomes available (and never fail the
25
- * plugin's load when a deployment has no fs).
41
+ * The command is fs-independent and registers immediately. The checkpoint
42
+ * pipeline needs `fs` to resolve tracked paths to their real display paths,
43
+ * so it mounts through a dynamic `ctx.inject(['fs'])` it takes effect
44
+ * whenever the fs service becomes available (and never fails the plugin's
45
+ * load when a deployment has no fs; without it, no entries are recorded and
46
+ * `both` restores report "no tracked changes").
26
47
  *
27
48
  * Capture runs in `tools/execute` (the around-dispatch stage), NOT in
28
49
  * `tools/pre-execute`: a pre-execute `{ kind: 'ask' }` short-circuit from
29
50
  * another plugin (e.g. dsh-edit-approval) skips later pre-execute listeners,
30
51
  * and a denied call never dispatches — so approved calls are still captured,
31
- * denied calls never leave a pending entry behind.
52
+ * denied calls never leave a pending entry behind. Entries are committed to
53
+ * disk at `tools/post-execute` under the turn's anchor message seq.
32
54
  *
33
55
  * @param ctx - context carrying `commands`, `tools`, and an optional `fs`.
56
+ * @param config - optional override of the checkpoint store root.
34
57
  */
35
- export declare function apply(ctx: Context): void;
58
+ export declare function apply(ctx: Context, config?: RewindConfig): void;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Session-cwd resolution for ledger reads/restores, mirroring the fs tools'
2
+ * Session-cwd resolution for snapshot tracking reads, mirroring the fs tools'
3
3
  * own rule (`@deepseek-ai/dsh-tool-fs/session-cwd.ts`): relative paths
4
4
  * resolve against the calling agent's session workspace
5
5
  * (`exec.agent.session.header.cwd`), not the server's launch dir.