dsh-rewind-plugin 0.3.0 → 0.3.2

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
@@ -1,6 +1,74 @@
1
1
  // src/index.ts
2
2
  import { createAssistantMessage } from "@deepseek-ai/dsh-llm";
3
3
  import { unlink } from "node:fs/promises";
4
+ import { settingsNamespace } from "@deepseek-ai/dsh-settings";
5
+
6
+ // src/locales.ts
7
+ var en = {
8
+ "usage.title": "Usage:",
9
+ "usage.noArgs": " /rewind (no args) withdraw the most recent user message",
10
+ "usage.seq": " /rewind @<seq> chat|both rewind to the given message (chat = conversation only / both = conversation + files)",
11
+ "usage.blocked": " Manual /rewind input is intercepted; use the \u21B6 button next to a message",
12
+ "describeTarget.seq": "seq {seq}",
13
+ "describeTarget.index": "message {index}",
14
+ "plan.rewinding": "Rewind to seq {targetSeq}, removing {count} node(s) from the model context (conversation log kept).",
15
+ "plan.affects": "Affects {count} file(s):",
16
+ "plan.restore": "restore {path}",
17
+ "plan.delete": "delete {path}",
18
+ "plan.noChanges": "No restorable changes after the target.",
19
+ "error.invalidTarget": 'Cannot parse target "{raw}" (expected <index> or @<seq>)',
20
+ "failures.suffix": "; {count} file(s) failed to restore: {list}",
21
+ "failures.item": "{path} ({message})",
22
+ "inflight": "A rewind is already running for this session; please wait.",
23
+ "stopFailed": "Could not stop the running agent; rewind cancelled. Please try again.",
24
+ "cancelled": "Rewind cancelled.",
25
+ "failed": "Rewind failed: {error}. The session is unchanged.",
26
+ "restore.count": "restored {count} file(s)",
27
+ "delete.count": "deleted {count} file(s)",
28
+ "skip.count": "skipped {count} link(s)",
29
+ "noRestorable": "; no restorable write-class changes after the target",
30
+ "success": "Withdrawn seq {targetSeq} and everything after it (conversation returned to earlier){restore}.",
31
+ "noUserMessages": "This session has no rewindable user messages yet.",
32
+ "chooseMode": "Rewind to {target}. Choose a mode:\n /rewind {target} chat conversation only\n /rewind {target} both conversation + file restore",
33
+ "command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)"
34
+ };
35
+ var zh = {
36
+ "usage.title": "\u7528\u6CD5\uFF1A",
37
+ "usage.noArgs": " /rewind \uFF08\u65E0\u53C2\u6570\uFF09\u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F",
38
+ "usage.seq": " /rewind @<seq> chat|both \u56DE\u9000\u5230\u6307\u5B9A\u6D88\u606F\uFF08chat \u4EC5\u5BF9\u8BDD / both \u5BF9\u8BDD+\u6587\u4EF6\uFF09",
39
+ "usage.blocked": " \u624B\u52A8\u8F93\u5165 /rewind \u4F1A\u88AB\u62E6\u622A\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684\u300C\u56DE\u9000\u300D\u6309\u94AE",
40
+ "describeTarget.seq": "seq {seq}",
41
+ "describeTarget.index": "\u7B2C {index} \u6761\u6D88\u606F",
42
+ "plan.rewinding": "\u5C06\u56DE\u9000\u5230 seq {targetSeq}\uFF0C\u4ECE\u6A21\u578B\u4E0A\u4E0B\u6587\u79FB\u9664 {count} \u4E2A\u8282\u70B9\uFF08\u5BF9\u8BDD\u65E5\u5FD7\u4FDD\u7559\uFF09\u3002",
43
+ "plan.affects": "\u5C06\u5F71\u54CD {count} \u4E2A\u6587\u4EF6\uFF1A",
44
+ "plan.restore": "\u8FD8\u539F {path}",
45
+ "plan.delete": "\u5220\u9664 {path}",
46
+ "plan.noChanges": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u9700\u8981\u8FD8\u539F\u7684\u53D8\u66F4\u3002",
47
+ "error.invalidTarget": '\u65E0\u6CD5\u89E3\u6790\u76EE\u6807 "{raw}"\uFF08\u5E94\u4E3A <\u5E8F\u53F7> \u6216 @<seq>\uFF09',
48
+ "failures.suffix": "\uFF1B{count} \u4E2A\u6587\u4EF6\u8FD8\u539F\u5931\u8D25\uFF1A{list}",
49
+ "failures.item": "{path}\uFF08{message}\uFF09",
50
+ "inflight": "\u8BE5\u4F1A\u8BDD\u5DF2\u6709\u4E00\u4E2A\u56DE\u9000\u6B63\u5728\u6267\u884C\uFF0C\u8BF7\u7A0D\u5019\u3002",
51
+ "stopFailed": "\u65E0\u6CD5\u505C\u6B62\u8FD0\u884C\u4E2D\u7684 agent\uFF0C\u56DE\u9000\u5DF2\u53D6\u6D88\u3002\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002",
52
+ "cancelled": "\u56DE\u9000\u5DF2\u53D6\u6D88\u3002",
53
+ "failed": "\u56DE\u9000\u5931\u8D25\uFF1A{error}\u3002\u4F1A\u8BDD\u672A\u6539\u53D8\u3002",
54
+ "restore.count": "\u8FD8\u539F {count} \u4E2A\u6587\u4EF6",
55
+ "delete.count": "\u5220\u9664 {count} \u4E2A\u6587\u4EF6",
56
+ "skip.count": "\u8DF3\u8FC7 {count} \u4E2A\u94FE\u63A5",
57
+ "noRestorable": "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4",
58
+ "success": "\u5DF2\u64A4\u56DE seq {targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09{restore}\u3002",
59
+ "noUserMessages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
60
+ "chooseMode": "\u5C06\u56DE\u9000\u5230 {target}\u3002\u9009\u62E9\u6A21\u5F0F\uFF1A\n /rewind {target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD\n /rewind {target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6",
61
+ "command.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"
62
+ };
63
+ var HOST_DICTS = { en, zh };
64
+ function translate(lang, key, params = {}) {
65
+ const dict = HOST_DICTS[lang] ?? en;
66
+ let text = dict[key] ?? key;
67
+ for (const [name2, value] of Object.entries(params)) {
68
+ text = text.split(`{${name2}}`).join(String(value));
69
+ }
70
+ return text;
71
+ }
4
72
 
5
73
  // src/rewind.ts
6
74
  var RewindError = class extends Error {
@@ -12,6 +80,7 @@ var RewindError = class extends Error {
12
80
  code;
13
81
  };
14
82
  var CANDIDATE_PREVIEW_CHARS = 80;
83
+ var DEFAULT_CANDIDATE_LIMIT = 50;
15
84
  function markerTurnOf(events) {
16
85
  let lastStarted = 0;
17
86
  for (const event of events) {
@@ -24,6 +93,9 @@ function markerTurnOf(events) {
24
93
  function isUserMessageEvent(event) {
25
94
  return event.type === "user/message";
26
95
  }
96
+ function isHumanUserMessageEvent(event) {
97
+ return isUserMessageEvent(event) && event.data.source.kind === "user";
98
+ }
27
99
  function messagePreview(message) {
28
100
  const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
29
101
  return text.length <= CANDIDATE_PREVIEW_CHARS ? text : `${text.slice(0, CANDIDATE_PREVIEW_CHARS - 1)}\u2026`;
@@ -38,13 +110,13 @@ function parseRewindTarget(raw) {
38
110
  const index = Number(token);
39
111
  return Number.isSafeInteger(index) && index >= 1 ? { kind: "index", index } : void 0;
40
112
  }
41
- function listRewindCandidates(events, surface, limit = 10) {
113
+ function listRewindCandidates(events, surface, limit = DEFAULT_CANDIDATE_LIMIT) {
42
114
  const surfaceIndexes = /* @__PURE__ */ new Map();
43
115
  for (let i = 0; i < surface.length; i++) surfaceIndexes.set(surface[i], i);
44
116
  const candidates = [];
45
117
  for (let i = events.length - 1; i >= 0 && candidates.length < limit; i--) {
46
118
  const event = events[i];
47
- if (!isUserMessageEvent(event)) continue;
119
+ if (!isHumanUserMessageEvent(event)) continue;
48
120
  if (!surfaceIndexes.has(event.seq)) continue;
49
121
  candidates.push({
50
122
  seq: event.seq,
@@ -55,6 +127,14 @@ function listRewindCandidates(events, surface, limit = 10) {
55
127
  }
56
128
  return candidates;
57
129
  }
130
+ var CANDIDATE_LIST_HEADER = "candidates=";
131
+ function formatCandidateList(candidates) {
132
+ const lines = [`${CANDIDATE_LIST_HEADER}${candidates.length}`];
133
+ for (const candidate of candidates) {
134
+ lines.push(`${candidate.seq} ${candidate.time} ${candidate.preview}`);
135
+ }
136
+ return lines.join("\n");
137
+ }
58
138
  function planRewind(events, surface, target) {
59
139
  let targetSeq;
60
140
  if (target.kind === "seq") {
@@ -70,10 +150,10 @@ function planRewind(events, surface, target) {
70
150
  if (targetEvent === void 0) {
71
151
  throw new RewindError("not-a-user-message", `no session event at seq ${targetSeq}`);
72
152
  }
73
- if (!isUserMessageEvent(targetEvent)) {
153
+ if (!isHumanUserMessageEvent(targetEvent)) {
74
154
  throw new RewindError(
75
155
  "not-a-user-message",
76
- `session event at seq ${targetSeq} is not a user message (${targetEvent.type})`
156
+ `session event at seq ${targetSeq} is not a human user message (${targetEvent.type})`
77
157
  );
78
158
  }
79
159
  const targetIndex = surface.indexOf(targetSeq);
@@ -105,12 +185,24 @@ function execSessionCwd(exec, requestedPath) {
105
185
  }
106
186
 
107
187
  // src/snapshot.ts
188
+ import { createHash } from "node:crypto";
108
189
  import { lstat, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
109
190
  import { dirname, join } from "node:path";
110
191
  import { homedir } from "node:os";
111
192
  var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
112
193
  var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
113
194
  var MAX_ANCHOR_GROUPS = 100;
195
+ var defaultProbe = {
196
+ async readText(path) {
197
+ try {
198
+ return await readFile(path, "utf8");
199
+ } catch (error) {
200
+ if (error.code === "ENOENT") return void 0;
201
+ throw error;
202
+ }
203
+ },
204
+ isLink: isLinkPath
205
+ };
114
206
  function safeFileId(callId) {
115
207
  return callId.replace(/[^a-zA-Z0-9._-]/g, "_");
116
208
  }
@@ -212,43 +304,99 @@ var SnapshotStore = class _SnapshotStore {
212
304
  }
213
305
  return earliest;
214
306
  }
215
- /** Per-file restore impact for the earliest entry at/after the target. */
216
- async impactsAfter(sessionId, targetSeq) {
217
- return [...(await this.earliestEntries(sessionId, targetSeq)).values()].sort((a, b) => a.path.localeCompare(b.path)).map((entry) => ({
218
- path: entry.path,
219
- action: entry.before === null ? "delete" : "restore"
220
- }));
221
- }
222
307
  /**
223
- * Restore the workspace to the target message's checkpoint: for every path
224
- * with entries anchored at or after it, apply the EARLIEST entry — write the
225
- * before content back, or delete the file when it was created after the
226
- * target. Symlinked and hard-linked paths are skipped (reported, never
227
- * written through); a restored file's parent directory is created when it
228
- * was deleted after the backup. Failures are per-file and never abort the
229
- * pass.
308
+ * The single source of truth for BOTH the impact preview and the restore
309
+ * pass: reconcile the earliest recorded entry per path (at/after the
310
+ * target) against the CURRENT on-disk state, and plan only the actions
311
+ * that would actually change the disk. This is the Claude Code model
312
+ * `fileHistoryGetDiffStats` / `applySnapshot` both compare against the
313
+ * live filesystem (`checkOriginFileChanged`) and count only real
314
+ * differences, so a rewind whose target state already matches the disk is
315
+ * a no-op with zero impact.
316
+ *
317
+ * - `before === null` (the file did not exist at the target) plans a
318
+ * `delete` ONLY when the file currently exists; an already-absent file
319
+ * is a no-op — this kills the "ghost impact" of replaying an entry a
320
+ * previous rewind already consumed.
321
+ * - `before === 'X'` plans a `restore` ONLY when the current content
322
+ * differs from X (or the file is missing); identical content is a no-op
323
+ * — this keeps repeated rewinds idempotent.
324
+ * - Symlinked / hard-linked paths are never planned (they are reported as
325
+ * skipped by the restore pass, never written through).
326
+ * - A probe failure (e.g. a permission error reading the file) plans the
327
+ * action conservatively as if the file differed, so an unreadable file
328
+ * is never silently dropped from the restore.
329
+ *
330
+ * @param sessionId - session whose snapshot store to plan against.
331
+ * @param targetSeq - rewind target; entries anchored at/after it apply.
332
+ * @param probe - current-disk state probe (defaults to the real FS).
333
+ * @returns the planned actions plus the link paths that were skipped.
230
334
  */
231
- async restoreAfter(sessionId, targetSeq, deleteFile) {
232
- const restored = [];
233
- const deleted = [];
335
+ async planRestore(sessionId, targetSeq, probe) {
336
+ const actions = [];
234
337
  const skipped = [];
235
- const failed = [];
236
338
  for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
237
339
  try {
238
- if (await isLinkPath(entry.path)) {
340
+ if (await probe.isLink(entry.path)) {
239
341
  skipped.push(entry.path);
240
342
  continue;
241
343
  }
344
+ const current = await probe.readText(entry.path);
345
+ if (entry.before === null) {
346
+ if (current !== void 0) actions.push({ path: entry.path, action: "delete" });
347
+ } else if (current !== entry.before) {
348
+ actions.push({ path: entry.path, action: "restore", before: entry.before });
349
+ }
350
+ } catch (error) {
242
351
  if (entry.before === null) {
243
- await deleteFile(entry.path);
244
- deleted.push(entry.path);
352
+ actions.push({ path: entry.path, action: "delete" });
245
353
  } else {
246
- await mkdir(dirname(entry.path), { recursive: true });
247
- await writeFile(entry.path, entry.before, "utf8");
248
- restored.push(entry.path);
354
+ actions.push({ path: entry.path, action: "restore", before: entry.before });
355
+ }
356
+ }
357
+ }
358
+ return { actions, skipped };
359
+ }
360
+ /** Per-file restore impact: only actions that would actually change the disk. */
361
+ async impactsAfter(sessionId, targetSeq, probe = defaultProbe) {
362
+ const { actions } = await this.planRestore(sessionId, targetSeq, probe);
363
+ return actions.sort((a, b) => a.path.localeCompare(b.path)).map((action) => ({ path: action.path, action: action.action }));
364
+ }
365
+ /**
366
+ * Restore the workspace to the target message's checkpoint: execute exactly
367
+ * the actions {@link planRestore} derived from the record + current disk
368
+ * reconciliation — write the before content back, or delete the file when
369
+ * it was created after the target and still exists. Symlinked and
370
+ * hard-linked paths are skipped (reported, never written through); a
371
+ * restored file's parent directory is created when it was deleted after
372
+ * the backup; a delete whose file is ALREADY absent is a silent no-op (not
373
+ * a failure — the target state is already reached). Failures are per-file
374
+ * and never abort the pass.
375
+ */
376
+ async restoreAfter(sessionId, targetSeq, deleteFile, probe = defaultProbe) {
377
+ const restored = [];
378
+ const deleted = [];
379
+ const skipped = [];
380
+ const failed = [];
381
+ const { actions, skipped: skippedPaths } = await this.planRestore(sessionId, targetSeq, probe);
382
+ skipped.push(...skippedPaths);
383
+ for (const action of actions) {
384
+ try {
385
+ if (action.action === "delete") {
386
+ try {
387
+ await deleteFile(action.path);
388
+ } catch (error) {
389
+ if (error.code !== "ENOENT") throw error;
390
+ continue;
391
+ }
392
+ deleted.push(action.path);
393
+ } else {
394
+ await mkdir(dirname(action.path), { recursive: true });
395
+ await writeFile(action.path, action.before, "utf8");
396
+ restored.push(action.path);
249
397
  }
250
398
  } catch (error) {
251
- failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
399
+ failed.push({ path: action.path, message: error instanceof Error ? error.message : String(error) });
252
400
  }
253
401
  }
254
402
  return { restored, deleted, skipped, failed };
@@ -283,19 +431,64 @@ var SnapshotStore = class _SnapshotStore {
283
431
  throw error;
284
432
  }
285
433
  }
434
+ /**
435
+ * All distinct paths ever recorded for a session — the "tracked files"
436
+ * set. Mirrors Claude Code's global `trackedFiles` collection (files stay
437
+ * tracked once a write-class tool touched them), derived from the disk
438
+ * entries so no extra persistence is needed.
439
+ */
440
+ async trackedPaths(sessionId) {
441
+ const paths = /* @__PURE__ */ new Set();
442
+ for (const entry of await this.entriesAfter(sessionId, 0)) {
443
+ paths.add(entry.path);
444
+ }
445
+ return paths;
446
+ }
286
447
  };
448
+ function hashPath(path) {
449
+ return createHash("sha256").update(path).digest("hex").slice(0, 8);
450
+ }
451
+ async function reconcileTracked(store, sessionId, anchorSeq, tracked, states, probe = defaultProbe) {
452
+ let recorded = 0;
453
+ for (const path of tracked) {
454
+ try {
455
+ if (await probe.isLink(path)) continue;
456
+ const current = await probe.readText(path);
457
+ const state = current ?? null;
458
+ const prev = states.get(path);
459
+ if (prev === void 0 || prev !== state) {
460
+ await store.recordEntry(sessionId, {
461
+ callId: `recheck-${anchorSeq}-${hashPath(path)}`,
462
+ anchorSeq,
463
+ path,
464
+ before: state
465
+ });
466
+ states.set(path, state);
467
+ recorded++;
468
+ }
469
+ } catch {
470
+ }
471
+ }
472
+ return recorded;
473
+ }
287
474
 
288
475
  // src/index.ts
289
476
  var name = "dsh-rewind";
290
477
  var inject = ["commands", "tools"];
291
478
  var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_editor"]);
292
479
  var MUTATING_EDITOR_COMMANDS = /* @__PURE__ */ new Set(["create", "str_replace", "insert"]);
293
- var USAGE = [
294
- "Usage:",
295
- " /rewind \uFF08\u65E0\u53C2\u6570\uFF09\u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F",
296
- " /rewind @<seq> chat|both \u56DE\u9000\u5230\u6307\u5B9A\u6D88\u606F\uFF08chat \u4EC5\u5BF9\u8BDD / both \u5BF9\u8BDD+\u6587\u4EF6\uFF09",
297
- " \u624B\u52A8\u8F93\u5165 /rewind \u4F1A\u88AB\u62E6\u622A\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684\u300C\u56DE\u9000\u300D\u6309\u94AE"
298
- ].join("\n");
480
+ var activeLocale = "en";
481
+ function t(key, params) {
482
+ return translate(activeLocale, key, params);
483
+ }
484
+ function usage() {
485
+ return [
486
+ t("usage.title"),
487
+ t("usage.noArgs"),
488
+ t("usage.seq"),
489
+ t("usage.blocked")
490
+ ].join("\n");
491
+ }
299
492
  function mutationPathOf(exec) {
300
493
  const args = exec.arguments;
301
494
  if (exec.name === "write" || exec.name === "edit") {
@@ -352,7 +545,7 @@ async function captureBefore(fs, exec, pending) {
352
545
  const before = await readTextOrUndefined(fs, target, exec.signal);
353
546
  pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, before });
354
547
  }
355
- async function commitEntry(store, pending, anchorCache, exec, result) {
548
+ async function commitEntry(store, pending, anchorCache, trackedBySession, exec, result) {
356
549
  const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
357
550
  const capture = pending.get(key);
358
551
  if (capture === void 0) return;
@@ -368,6 +561,12 @@ async function commitEntry(store, pending, anchorCache, exec, result) {
368
561
  path: capture.path,
369
562
  before: capture.before ?? null
370
563
  });
564
+ let tracked = trackedBySession.get(agent.session.id);
565
+ if (tracked === void 0) {
566
+ tracked = /* @__PURE__ */ new Set();
567
+ trackedBySession.set(agent.session.id, tracked);
568
+ }
569
+ tracked.add(capture.path);
371
570
  }
372
571
  function buildMarker() {
373
572
  return createAssistantMessage({
@@ -376,33 +575,62 @@ function buildMarker() {
376
575
  });
377
576
  }
378
577
  function describeTarget(target) {
379
- return target.kind === "seq" ? `seq ${target.seq}` : `\u7B2C ${target.index} \u6761\u6D88\u606F`;
578
+ return target.kind === "seq" ? t("describeTarget.seq", { seq: target.seq }) : t("describeTarget.index", { index: target.index });
380
579
  }
381
580
  function formatPlan(plan, files) {
382
581
  const lines = [
383
- `\u5C06\u56DE\u9000\u5230 seq ${plan.targetSeq}\uFF0C\u4ECE\u6A21\u578B\u4E0A\u4E0B\u6587\u79FB\u9664 ${plan.shadowedSeqs.length} \u4E2A\u8282\u70B9\uFF08\u5BF9\u8BDD\u65E5\u5FD7\u4FDD\u7559\uFF09\u3002`
582
+ t("plan.rewinding", { targetSeq: plan.targetSeq, count: plan.shadowedSeqs.length })
384
583
  ];
385
584
  if (files.length > 0) {
386
- lines.push(`\u5C06\u5F71\u54CD ${files.length} \u4E2A\u6587\u4EF6\uFF1A`);
585
+ lines.push(t("plan.affects", { count: files.length }));
387
586
  for (const file of files) {
388
- lines.push(` ${file.action === "restore" ? "\u8FD8\u539F" : "\u5220\u9664"} ${file.path}`);
587
+ lines.push(` ${file.action === "restore" ? t("plan.restore", { path: file.path }) : t("plan.delete", { path: file.path })}`);
389
588
  }
390
589
  } else {
391
- 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");
590
+ lines.push(t("plan.noChanges"));
392
591
  }
393
592
  lines.push(`impact=${files.length}`);
593
+ for (const file of files) {
594
+ lines.push(`${file.action}:${file.path}`);
595
+ }
394
596
  return lines.join("\n");
395
597
  }
396
598
  function resolveOrError(events, surface, raw) {
397
599
  const target = parseRewindTarget(raw);
398
600
  if (target === void 0) {
399
- throw new RewindError("invalid-index", `\u65E0\u6CD5\u89E3\u6790\u76EE\u6807 "${raw}"\uFF08\u5E94\u4E3A <\u5E8F\u53F7> \u6216 @<seq>\uFF09`);
601
+ throw new RewindError("invalid-index", t("error.invalidTarget", { raw }));
400
602
  }
401
603
  return planRewind(events, surface, target);
402
604
  }
403
605
  function renderFailures(failed) {
404
606
  if (failed.length === 0) return "";
405
- return `\uFF1B${failed.length} \u4E2A\u6587\u4EF6\u8FD8\u539F\u5931\u8D25\uFF1A${failed.map((f) => `${f.path}\uFF08${f.message}\uFF09`).join("\u3001")}`;
607
+ return t("failures.suffix", {
608
+ count: failed.length,
609
+ list: failed.map((f) => t("failures.item", { path: f.path, message: f.message })).join("\u3001")
610
+ });
611
+ }
612
+ async function resolveObservationTarget(fs, path) {
613
+ try {
614
+ return await fs.resolve(path);
615
+ } catch {
616
+ return void 0;
617
+ }
618
+ }
619
+ async function syncRestoreObservations(ctx, fs, agent, outcome) {
620
+ if (fs === void 0) return;
621
+ const actor = { agent };
622
+ for (const path of outcome.deleted) {
623
+ const target = await resolveObservationTarget(fs, path);
624
+ if (target === void 0) continue;
625
+ ctx.emit("fs/observed", target, { kind: "absent" }, actor);
626
+ }
627
+ for (const path of outcome.restored) {
628
+ const target = await resolveObservationTarget(fs, path);
629
+ if (target === void 0) continue;
630
+ const info = await fs.stat(target);
631
+ if (info === void 0) continue;
632
+ ctx.emit("fs/observed", target, { kind: "present", version: info.version }, actor);
633
+ }
406
634
  }
407
635
  async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
408
636
  if (signal.aborted) return false;
@@ -425,11 +653,11 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
425
653
  if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
426
654
  }
427
655
  }
428
- async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight) {
656
+ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflight) {
429
657
  const { agent } = invocation;
430
658
  const sessionId = agent.session.id;
431
659
  if (inflight.has(sessionId)) {
432
- return { kind: "error", text: "\u8BE5\u4F1A\u8BDD\u5DF2\u6709\u4E00\u4E2A\u56DE\u9000\u6B63\u5728\u6267\u884C\uFF0C\u8BF7\u7A0D\u5019\u3002" };
660
+ return { kind: "error", text: t("inflight") };
433
661
  }
434
662
  inflight.add(sessionId);
435
663
  try {
@@ -437,11 +665,11 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
437
665
  agent.cancel({ kind: "user" });
438
666
  const stopped = await waitForAgentIdle(agent, invocation.signal);
439
667
  if (!stopped) {
440
- return { kind: "error", text: "\u65E0\u6CD5\u505C\u6B62\u8FD0\u884C\u4E2D\u7684 agent\uFF0C\u56DE\u9000\u5DF2\u53D6\u6D88\u3002\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002" };
668
+ return { kind: "error", text: t("stopFailed") };
441
669
  }
442
670
  }
443
671
  if (invocation.signal.aborted) {
444
- return { kind: "error", text: "\u56DE\u9000\u5DF2\u53D6\u6D88\u3002" };
672
+ return { kind: "error", text: t("cancelled") };
445
673
  }
446
674
  let plan;
447
675
  try {
@@ -459,22 +687,23 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
459
687
  } catch (error) {
460
688
  return {
461
689
  kind: "error",
462
- text: `\u56DE\u9000\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}\u3002\u4F1A\u8BDD\u672A\u6539\u53D8\u3002`
690
+ text: t("failed", { error: error instanceof Error ? error.message : String(error) })
463
691
  };
464
692
  }
465
693
  let restore = "";
466
694
  if (mode === "both") {
467
695
  const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
696
+ await syncRestoreObservations(ctx, fs, agent, outcome);
468
697
  const parts = [];
469
- if (outcome.restored.length > 0) parts.push(`\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6`);
470
- if (outcome.deleted.length > 0) parts.push(`\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6`);
471
- if (outcome.skipped.length > 0) parts.push(`\u8DF3\u8FC7 ${outcome.skipped.length} \u4E2A\u94FE\u63A5`);
472
- restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4";
698
+ if (outcome.restored.length > 0) parts.push(t("restore.count", { count: outcome.restored.length }));
699
+ if (outcome.deleted.length > 0) parts.push(t("delete.count", { count: outcome.deleted.length }));
700
+ if (outcome.skipped.length > 0) parts.push(t("skip.count", { count: outcome.skipped.length }));
701
+ restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : t("noRestorable");
473
702
  restore += renderFailures(outcome.failed);
474
703
  }
475
704
  return {
476
705
  kind: "success",
477
- text: `\u5DF2\u64A4\u56DE seq ${plan.targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09${restore}\u3002`,
706
+ text: t("success", { targetSeq: plan.targetSeq, restore }),
478
707
  sourceEventSeq: event.seq
479
708
  };
480
709
  } finally {
@@ -484,7 +713,7 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
484
713
  function rewindErrorResult(error) {
485
714
  if (error instanceof RewindError) {
486
715
  const text = {
487
- "no-user-messages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
716
+ "no-user-messages": t("noUserMessages"),
488
717
  "invalid-index": error.message,
489
718
  "not-a-user-message": error.message,
490
719
  "not-on-surface": error.message
@@ -493,20 +722,20 @@ function rewindErrorResult(error) {
493
722
  }
494
723
  throw error;
495
724
  }
496
- async function handleRewind(ctx, store, invocation, inflight) {
725
+ async function handleRewind(ctx, store, fs, invocation, inflight) {
497
726
  const session = invocation.agent.session;
498
727
  const input = invocation.rawInput.trim();
499
728
  if (input === "") {
500
729
  const candidates = listRewindCandidates(session.events, session.surface.nodes, 1);
501
730
  if (candidates.length === 0) {
502
- return { kind: "error", text: "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002" };
731
+ return { kind: "error", text: t("noUserMessages") };
503
732
  }
504
- return executeRewind(ctx, store, invocation, `@${candidates[0].seq}`, "chat", inflight);
733
+ return executeRewind(ctx, store, fs, invocation, `@${candidates[0].seq}`, "chat", inflight);
505
734
  }
506
735
  const parts = input.split(/\s+/);
507
736
  if (parts[0] === "preview") {
508
737
  const target2 = parts[1];
509
- if (target2 === void 0) return { kind: "error", text: USAGE };
738
+ if (target2 === void 0) return { kind: "error", text: usage() };
510
739
  let plan;
511
740
  try {
512
741
  plan = resolveOrError(session.events, session.surface.nodes, target2);
@@ -516,37 +745,73 @@ async function handleRewind(ctx, store, invocation, inflight) {
516
745
  const impacts = await store.impactsAfter(session.id, plan.targetSeq);
517
746
  return { kind: "success", text: formatPlan(plan, impacts) };
518
747
  }
748
+ if (parts[0] === "__candidates") {
749
+ const candidates = listRewindCandidates(session.events, session.surface.nodes);
750
+ return { kind: "success", text: formatCandidateList(candidates) };
751
+ }
519
752
  const target = parts[0];
520
753
  const mode = parts[1];
521
754
  if (mode !== void 0 && mode !== "chat" && mode !== "both") {
522
- return { kind: "error", text: USAGE };
755
+ return { kind: "error", text: usage() };
523
756
  }
524
757
  if (mode === void 0) {
525
758
  const parsed = parseRewindTarget(target);
526
- if (parsed === void 0) return { kind: "error", text: USAGE };
759
+ if (parsed === void 0) return { kind: "error", text: usage() };
527
760
  return {
528
761
  kind: "success",
529
- text: `\u5C06\u56DE\u9000\u5230 ${describeTarget(parsed)}\u3002\u9009\u62E9\u6A21\u5F0F\uFF1A
530
- /rewind ${target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD
531
- /rewind ${target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6`
762
+ text: t("chooseMode", { target: describeTarget(parsed) })
532
763
  };
533
764
  }
534
- return executeRewind(ctx, store, invocation, target, mode, inflight);
765
+ return executeRewind(ctx, store, fs, invocation, target, mode, inflight);
535
766
  }
536
767
  function apply(ctx, config) {
537
768
  const store = new SnapshotStore(config?.snapshotDir);
538
769
  const pending = /* @__PURE__ */ new Map();
539
770
  const anchorCache = /* @__PURE__ */ new WeakMap();
540
771
  const inflight = /* @__PURE__ */ new Set();
772
+ const trackedBySession = /* @__PURE__ */ new Map();
773
+ const statesBySession = /* @__PURE__ */ new Map();
774
+ let fsService;
775
+ ctx.inject(["settings"], (settingsCtx) => {
776
+ const section = settingsCtx.settings.get(settingsNamespace("locale"));
777
+ if (section?.preference === "zh" || section?.preference === "en") {
778
+ activeLocale = section.preference;
779
+ }
780
+ });
541
781
  ctx.effect(function* () {
542
782
  yield ctx.commands.register({
543
783
  name: "rewind",
544
- 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",
545
- handler: (invocation) => handleRewind(ctx, store, invocation, inflight)
784
+ description: t("command.description"),
785
+ handler: (invocation) => handleRewind(ctx, store, fsService, invocation, inflight)
546
786
  });
547
787
  }, "dsh-rewind command");
788
+ ctx.on("session/event", (session, event) => {
789
+ if (event.type !== "user/message") return;
790
+ const header = session.header;
791
+ if (header.origin === "subagent" || (header.delegationDepth ?? 0) > 0) return;
792
+ void (async () => {
793
+ try {
794
+ const sessionId = session.id;
795
+ let tracked = trackedBySession.get(sessionId);
796
+ if (tracked === void 0) {
797
+ tracked = await store.trackedPaths(sessionId);
798
+ trackedBySession.set(sessionId, tracked);
799
+ }
800
+ if (tracked.size === 0) return;
801
+ let states = statesBySession.get(sessionId);
802
+ if (states === void 0) {
803
+ states = /* @__PURE__ */ new Map();
804
+ statesBySession.set(sessionId, states);
805
+ }
806
+ await reconcileTracked(store, sessionId, event.seq, tracked, states);
807
+ } catch (error) {
808
+ ctx.logger.warn(`[dsh-rewind] boundary re-check failed: ${error instanceof Error ? error.message : String(error)}`);
809
+ }
810
+ })();
811
+ }, { global: true });
548
812
  ctx.inject(["fs"], (scope) => {
549
813
  const fs = scope.fs;
814
+ fsService = fs;
550
815
  scope.on("tools/execute", async (exec, next) => {
551
816
  try {
552
817
  await captureBefore(fs, exec, pending);
@@ -557,7 +822,7 @@ function apply(ctx, config) {
557
822
  });
558
823
  scope.on("tools/post-execute", async (exec, result, next) => {
559
824
  try {
560
- await commitEntry(store, pending, anchorCache, exec, result);
825
+ await commitEntry(store, pending, anchorCache, trackedBySession, exec, result);
561
826
  } catch (error) {
562
827
  ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
563
828
  }
@@ -15,6 +15,14 @@ import type { RewindKey } from './locales.ts';
15
15
  type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
16
16
  /** Preview length cap for candidate rows (matches the host's candidate list). */
17
17
  export declare const PREVIEW_CHARS = 80;
18
+ /**
19
+ * Default cap on how many user messages the rewind picker lists (newest kept).
20
+ *
21
+ * A fixed 10 made long sessions look "incomplete" (only the newest 10 shown).
22
+ * 50 keeps the picker scrollable/searchable via the popupSelect shell while
23
+ * covering far longer sessions; callers can still pass an explicit `limit`.
24
+ */
25
+ export declare const DEFAULT_CANDIDATE_LIMIT = 50;
18
26
  /** One selectable rewind target. */
19
27
  export interface RewindCandidate {
20
28
  /** Absolute log seq of the `user/message` event. */
@@ -73,4 +81,21 @@ export declare function rewindCandidatesOfChat(snap: CandidateChat): RewindCandi
73
81
  export declare function rewindOptionsOf(snap: CandidateChat, t: Translate): SelectOption[];
74
82
  /** Resolve one candidate by log seq (the mode popover's re-entry after a pick). */
75
83
  export declare function candidateBySeq(snap: CandidateChat, seq: number): RewindCandidate | undefined;
84
+ /**
85
+ * Parse the host's candidate-list encoding (see `formatCandidateList` in
86
+ * src/rewind.ts) into typed candidates. Malformed lines are skipped; a
87
+ * missing/zero header yields an empty list.
88
+ */
89
+ export declare function rewindCandidatesFromHostText(text: string): RewindCandidate[];
90
+ /**
91
+ * Map typed candidates to popupSelect rows (the host-derived path). The
92
+ * popupSelect sources its options from the FULL host surface via the
93
+ * `__candidates` channel instead of the windowed chat snapshot.
94
+ */
95
+ export declare function rewindOptionsFromCandidates(candidates: readonly RewindCandidate[], t: Translate): SelectOption[];
96
+ /**
97
+ * Parse the host's candidate-list encoding (see `formatCandidateList` in
98
+ * src/rewind.ts) into popupSelect rows.
99
+ */
100
+ export declare function rewindOptionsFromHostText(text: string, t: Translate): SelectOption[];
76
101
  export {};