dsh-rewind-plugin 0.2.9 → 0.3.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
@@ -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 {
@@ -105,12 +173,24 @@ function execSessionCwd(exec, requestedPath) {
105
173
  }
106
174
 
107
175
  // src/snapshot.ts
176
+ import { createHash } from "node:crypto";
108
177
  import { lstat, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
109
178
  import { dirname, join } from "node:path";
110
179
  import { homedir } from "node:os";
111
180
  var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
112
181
  var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
113
182
  var MAX_ANCHOR_GROUPS = 100;
183
+ var defaultProbe = {
184
+ async readText(path) {
185
+ try {
186
+ return await readFile(path, "utf8");
187
+ } catch (error) {
188
+ if (error.code === "ENOENT") return void 0;
189
+ throw error;
190
+ }
191
+ },
192
+ isLink: isLinkPath
193
+ };
114
194
  function safeFileId(callId) {
115
195
  return callId.replace(/[^a-zA-Z0-9._-]/g, "_");
116
196
  }
@@ -212,43 +292,99 @@ var SnapshotStore = class _SnapshotStore {
212
292
  }
213
293
  return earliest;
214
294
  }
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
295
  /**
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.
296
+ * The single source of truth for BOTH the impact preview and the restore
297
+ * pass: reconcile the earliest recorded entry per path (at/after the
298
+ * target) against the CURRENT on-disk state, and plan only the actions
299
+ * that would actually change the disk. This is the Claude Code model
300
+ * `fileHistoryGetDiffStats` / `applySnapshot` both compare against the
301
+ * live filesystem (`checkOriginFileChanged`) and count only real
302
+ * differences, so a rewind whose target state already matches the disk is
303
+ * a no-op with zero impact.
304
+ *
305
+ * - `before === null` (the file did not exist at the target) plans a
306
+ * `delete` ONLY when the file currently exists; an already-absent file
307
+ * is a no-op — this kills the "ghost impact" of replaying an entry a
308
+ * previous rewind already consumed.
309
+ * - `before === 'X'` plans a `restore` ONLY when the current content
310
+ * differs from X (or the file is missing); identical content is a no-op
311
+ * — this keeps repeated rewinds idempotent.
312
+ * - Symlinked / hard-linked paths are never planned (they are reported as
313
+ * skipped by the restore pass, never written through).
314
+ * - A probe failure (e.g. a permission error reading the file) plans the
315
+ * action conservatively as if the file differed, so an unreadable file
316
+ * is never silently dropped from the restore.
317
+ *
318
+ * @param sessionId - session whose snapshot store to plan against.
319
+ * @param targetSeq - rewind target; entries anchored at/after it apply.
320
+ * @param probe - current-disk state probe (defaults to the real FS).
321
+ * @returns the planned actions plus the link paths that were skipped.
230
322
  */
231
- async restoreAfter(sessionId, targetSeq, deleteFile) {
232
- const restored = [];
233
- const deleted = [];
323
+ async planRestore(sessionId, targetSeq, probe) {
324
+ const actions = [];
234
325
  const skipped = [];
235
- const failed = [];
236
326
  for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
237
327
  try {
238
- if (await isLinkPath(entry.path)) {
328
+ if (await probe.isLink(entry.path)) {
239
329
  skipped.push(entry.path);
240
330
  continue;
241
331
  }
332
+ const current = await probe.readText(entry.path);
242
333
  if (entry.before === null) {
243
- await deleteFile(entry.path);
244
- deleted.push(entry.path);
334
+ if (current !== void 0) actions.push({ path: entry.path, action: "delete" });
335
+ } else if (current !== entry.before) {
336
+ actions.push({ path: entry.path, action: "restore", before: entry.before });
337
+ }
338
+ } catch (error) {
339
+ if (entry.before === null) {
340
+ actions.push({ path: entry.path, action: "delete" });
341
+ } else {
342
+ actions.push({ path: entry.path, action: "restore", before: entry.before });
343
+ }
344
+ }
345
+ }
346
+ return { actions, skipped };
347
+ }
348
+ /** Per-file restore impact: only actions that would actually change the disk. */
349
+ async impactsAfter(sessionId, targetSeq, probe = defaultProbe) {
350
+ const { actions } = await this.planRestore(sessionId, targetSeq, probe);
351
+ return actions.sort((a, b) => a.path.localeCompare(b.path)).map((action) => ({ path: action.path, action: action.action }));
352
+ }
353
+ /**
354
+ * Restore the workspace to the target message's checkpoint: execute exactly
355
+ * the actions {@link planRestore} derived from the record + current disk
356
+ * reconciliation — write the before content back, or delete the file when
357
+ * it was created after the target and still exists. Symlinked and
358
+ * hard-linked paths are skipped (reported, never written through); a
359
+ * restored file's parent directory is created when it was deleted after
360
+ * the backup; a delete whose file is ALREADY absent is a silent no-op (not
361
+ * a failure — the target state is already reached). Failures are per-file
362
+ * and never abort the pass.
363
+ */
364
+ async restoreAfter(sessionId, targetSeq, deleteFile, probe = defaultProbe) {
365
+ const restored = [];
366
+ const deleted = [];
367
+ const skipped = [];
368
+ const failed = [];
369
+ const { actions, skipped: skippedPaths } = await this.planRestore(sessionId, targetSeq, probe);
370
+ skipped.push(...skippedPaths);
371
+ for (const action of actions) {
372
+ try {
373
+ if (action.action === "delete") {
374
+ try {
375
+ await deleteFile(action.path);
376
+ } catch (error) {
377
+ if (error.code !== "ENOENT") throw error;
378
+ continue;
379
+ }
380
+ deleted.push(action.path);
245
381
  } else {
246
- await mkdir(dirname(entry.path), { recursive: true });
247
- await writeFile(entry.path, entry.before, "utf8");
248
- restored.push(entry.path);
382
+ await mkdir(dirname(action.path), { recursive: true });
383
+ await writeFile(action.path, action.before, "utf8");
384
+ restored.push(action.path);
249
385
  }
250
386
  } catch (error) {
251
- failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
387
+ failed.push({ path: action.path, message: error instanceof Error ? error.message : String(error) });
252
388
  }
253
389
  }
254
390
  return { restored, deleted, skipped, failed };
@@ -283,19 +419,64 @@ var SnapshotStore = class _SnapshotStore {
283
419
  throw error;
284
420
  }
285
421
  }
422
+ /**
423
+ * All distinct paths ever recorded for a session — the "tracked files"
424
+ * set. Mirrors Claude Code's global `trackedFiles` collection (files stay
425
+ * tracked once a write-class tool touched them), derived from the disk
426
+ * entries so no extra persistence is needed.
427
+ */
428
+ async trackedPaths(sessionId) {
429
+ const paths = /* @__PURE__ */ new Set();
430
+ for (const entry of await this.entriesAfter(sessionId, 0)) {
431
+ paths.add(entry.path);
432
+ }
433
+ return paths;
434
+ }
286
435
  };
436
+ function hashPath(path) {
437
+ return createHash("sha256").update(path).digest("hex").slice(0, 8);
438
+ }
439
+ async function reconcileTracked(store, sessionId, anchorSeq, tracked, states, probe = defaultProbe) {
440
+ let recorded = 0;
441
+ for (const path of tracked) {
442
+ try {
443
+ if (await probe.isLink(path)) continue;
444
+ const current = await probe.readText(path);
445
+ const state = current ?? null;
446
+ const prev = states.get(path);
447
+ if (prev === void 0 || prev !== state) {
448
+ await store.recordEntry(sessionId, {
449
+ callId: `recheck-${anchorSeq}-${hashPath(path)}`,
450
+ anchorSeq,
451
+ path,
452
+ before: state
453
+ });
454
+ states.set(path, state);
455
+ recorded++;
456
+ }
457
+ } catch {
458
+ }
459
+ }
460
+ return recorded;
461
+ }
287
462
 
288
463
  // src/index.ts
289
464
  var name = "dsh-rewind";
290
465
  var inject = ["commands", "tools"];
291
466
  var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_editor"]);
292
467
  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");
468
+ var activeLocale = "en";
469
+ function t(key, params) {
470
+ return translate(activeLocale, key, params);
471
+ }
472
+ function usage() {
473
+ return [
474
+ t("usage.title"),
475
+ t("usage.noArgs"),
476
+ t("usage.seq"),
477
+ t("usage.blocked")
478
+ ].join("\n");
479
+ }
299
480
  function mutationPathOf(exec) {
300
481
  const args = exec.arguments;
301
482
  if (exec.name === "write" || exec.name === "edit") {
@@ -352,7 +533,7 @@ async function captureBefore(fs, exec, pending) {
352
533
  const before = await readTextOrUndefined(fs, target, exec.signal);
353
534
  pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, before });
354
535
  }
355
- async function commitEntry(store, pending, anchorCache, exec, result) {
536
+ async function commitEntry(store, pending, anchorCache, trackedBySession, exec, result) {
356
537
  const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
357
538
  const capture = pending.get(key);
358
539
  if (capture === void 0) return;
@@ -368,6 +549,12 @@ async function commitEntry(store, pending, anchorCache, exec, result) {
368
549
  path: capture.path,
369
550
  before: capture.before ?? null
370
551
  });
552
+ let tracked = trackedBySession.get(agent.session.id);
553
+ if (tracked === void 0) {
554
+ tracked = /* @__PURE__ */ new Set();
555
+ trackedBySession.set(agent.session.id, tracked);
556
+ }
557
+ tracked.add(capture.path);
371
558
  }
372
559
  function buildMarker() {
373
560
  return createAssistantMessage({
@@ -376,33 +563,62 @@ function buildMarker() {
376
563
  });
377
564
  }
378
565
  function describeTarget(target) {
379
- return target.kind === "seq" ? `seq ${target.seq}` : `\u7B2C ${target.index} \u6761\u6D88\u606F`;
566
+ return target.kind === "seq" ? t("describeTarget.seq", { seq: target.seq }) : t("describeTarget.index", { index: target.index });
380
567
  }
381
568
  function formatPlan(plan, files) {
382
569
  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`
570
+ t("plan.rewinding", { targetSeq: plan.targetSeq, count: plan.shadowedSeqs.length })
384
571
  ];
385
572
  if (files.length > 0) {
386
- lines.push(`\u5C06\u5F71\u54CD ${files.length} \u4E2A\u6587\u4EF6\uFF1A`);
573
+ lines.push(t("plan.affects", { count: files.length }));
387
574
  for (const file of files) {
388
- lines.push(` ${file.action === "restore" ? "\u8FD8\u539F" : "\u5220\u9664"} ${file.path}`);
575
+ lines.push(` ${file.action === "restore" ? t("plan.restore", { path: file.path }) : t("plan.delete", { path: file.path })}`);
389
576
  }
390
577
  } 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");
578
+ lines.push(t("plan.noChanges"));
392
579
  }
393
580
  lines.push(`impact=${files.length}`);
581
+ for (const file of files) {
582
+ lines.push(`${file.action}:${file.path}`);
583
+ }
394
584
  return lines.join("\n");
395
585
  }
396
586
  function resolveOrError(events, surface, raw) {
397
587
  const target = parseRewindTarget(raw);
398
588
  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`);
589
+ throw new RewindError("invalid-index", t("error.invalidTarget", { raw }));
400
590
  }
401
591
  return planRewind(events, surface, target);
402
592
  }
403
593
  function renderFailures(failed) {
404
594
  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")}`;
595
+ return t("failures.suffix", {
596
+ count: failed.length,
597
+ list: failed.map((f) => t("failures.item", { path: f.path, message: f.message })).join("\u3001")
598
+ });
599
+ }
600
+ async function resolveObservationTarget(fs, path) {
601
+ try {
602
+ return await fs.resolve(path);
603
+ } catch {
604
+ return void 0;
605
+ }
606
+ }
607
+ async function syncRestoreObservations(ctx, fs, agent, outcome) {
608
+ if (fs === void 0) return;
609
+ const actor = { agent };
610
+ for (const path of outcome.deleted) {
611
+ const target = await resolveObservationTarget(fs, path);
612
+ if (target === void 0) continue;
613
+ ctx.emit("fs/observed", target, { kind: "absent" }, actor);
614
+ }
615
+ for (const path of outcome.restored) {
616
+ const target = await resolveObservationTarget(fs, path);
617
+ if (target === void 0) continue;
618
+ const info = await fs.stat(target);
619
+ if (info === void 0) continue;
620
+ ctx.emit("fs/observed", target, { kind: "present", version: info.version }, actor);
621
+ }
406
622
  }
407
623
  async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
408
624
  if (signal.aborted) return false;
@@ -425,11 +641,11 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
425
641
  if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
426
642
  }
427
643
  }
428
- async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight) {
644
+ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflight) {
429
645
  const { agent } = invocation;
430
646
  const sessionId = agent.session.id;
431
647
  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" };
648
+ return { kind: "error", text: t("inflight") };
433
649
  }
434
650
  inflight.add(sessionId);
435
651
  try {
@@ -437,11 +653,11 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
437
653
  agent.cancel({ kind: "user" });
438
654
  const stopped = await waitForAgentIdle(agent, invocation.signal);
439
655
  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" };
656
+ return { kind: "error", text: t("stopFailed") };
441
657
  }
442
658
  }
443
659
  if (invocation.signal.aborted) {
444
- return { kind: "error", text: "\u56DE\u9000\u5DF2\u53D6\u6D88\u3002" };
660
+ return { kind: "error", text: t("cancelled") };
445
661
  }
446
662
  let plan;
447
663
  try {
@@ -459,22 +675,23 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
459
675
  } catch (error) {
460
676
  return {
461
677
  kind: "error",
462
- text: `\u56DE\u9000\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}\u3002\u4F1A\u8BDD\u672A\u6539\u53D8\u3002`
678
+ text: t("failed", { error: error instanceof Error ? error.message : String(error) })
463
679
  };
464
680
  }
465
681
  let restore = "";
466
682
  if (mode === "both") {
467
683
  const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
684
+ await syncRestoreObservations(ctx, fs, agent, outcome);
468
685
  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";
686
+ if (outcome.restored.length > 0) parts.push(t("restore.count", { count: outcome.restored.length }));
687
+ if (outcome.deleted.length > 0) parts.push(t("delete.count", { count: outcome.deleted.length }));
688
+ if (outcome.skipped.length > 0) parts.push(t("skip.count", { count: outcome.skipped.length }));
689
+ restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : t("noRestorable");
473
690
  restore += renderFailures(outcome.failed);
474
691
  }
475
692
  return {
476
693
  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`,
694
+ text: t("success", { targetSeq: plan.targetSeq, restore }),
478
695
  sourceEventSeq: event.seq
479
696
  };
480
697
  } finally {
@@ -484,7 +701,7 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
484
701
  function rewindErrorResult(error) {
485
702
  if (error instanceof RewindError) {
486
703
  const text = {
487
- "no-user-messages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
704
+ "no-user-messages": t("noUserMessages"),
488
705
  "invalid-index": error.message,
489
706
  "not-a-user-message": error.message,
490
707
  "not-on-surface": error.message
@@ -493,20 +710,20 @@ function rewindErrorResult(error) {
493
710
  }
494
711
  throw error;
495
712
  }
496
- async function handleRewind(ctx, store, invocation, inflight) {
713
+ async function handleRewind(ctx, store, fs, invocation, inflight) {
497
714
  const session = invocation.agent.session;
498
715
  const input = invocation.rawInput.trim();
499
716
  if (input === "") {
500
717
  const candidates = listRewindCandidates(session.events, session.surface.nodes, 1);
501
718
  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" };
719
+ return { kind: "error", text: t("noUserMessages") };
503
720
  }
504
- return executeRewind(ctx, store, invocation, `@${candidates[0].seq}`, "chat", inflight);
721
+ return executeRewind(ctx, store, fs, invocation, `@${candidates[0].seq}`, "chat", inflight);
505
722
  }
506
723
  const parts = input.split(/\s+/);
507
724
  if (parts[0] === "preview") {
508
725
  const target2 = parts[1];
509
- if (target2 === void 0) return { kind: "error", text: USAGE };
726
+ if (target2 === void 0) return { kind: "error", text: usage() };
510
727
  let plan;
511
728
  try {
512
729
  plan = resolveOrError(session.events, session.surface.nodes, target2);
@@ -519,34 +736,66 @@ async function handleRewind(ctx, store, invocation, inflight) {
519
736
  const target = parts[0];
520
737
  const mode = parts[1];
521
738
  if (mode !== void 0 && mode !== "chat" && mode !== "both") {
522
- return { kind: "error", text: USAGE };
739
+ return { kind: "error", text: usage() };
523
740
  }
524
741
  if (mode === void 0) {
525
742
  const parsed = parseRewindTarget(target);
526
- if (parsed === void 0) return { kind: "error", text: USAGE };
743
+ if (parsed === void 0) return { kind: "error", text: usage() };
527
744
  return {
528
745
  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`
746
+ text: t("chooseMode", { target: describeTarget(parsed) })
532
747
  };
533
748
  }
534
- return executeRewind(ctx, store, invocation, target, mode, inflight);
749
+ return executeRewind(ctx, store, fs, invocation, target, mode, inflight);
535
750
  }
536
751
  function apply(ctx, config) {
537
752
  const store = new SnapshotStore(config?.snapshotDir);
538
753
  const pending = /* @__PURE__ */ new Map();
539
754
  const anchorCache = /* @__PURE__ */ new WeakMap();
540
755
  const inflight = /* @__PURE__ */ new Set();
756
+ const trackedBySession = /* @__PURE__ */ new Map();
757
+ const statesBySession = /* @__PURE__ */ new Map();
758
+ let fsService;
759
+ ctx.inject(["settings"], (settingsCtx) => {
760
+ const section = settingsCtx.settings.get(settingsNamespace("locale"));
761
+ if (section?.preference === "zh" || section?.preference === "en") {
762
+ activeLocale = section.preference;
763
+ }
764
+ });
541
765
  ctx.effect(function* () {
542
766
  yield ctx.commands.register({
543
767
  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)
768
+ description: t("command.description"),
769
+ handler: (invocation) => handleRewind(ctx, store, fsService, invocation, inflight)
546
770
  });
547
771
  }, "dsh-rewind command");
772
+ ctx.on("session/event", (session, event) => {
773
+ if (event.type !== "user/message") return;
774
+ const header = session.header;
775
+ if (header.origin === "subagent" || (header.delegationDepth ?? 0) > 0) return;
776
+ void (async () => {
777
+ try {
778
+ const sessionId = session.id;
779
+ let tracked = trackedBySession.get(sessionId);
780
+ if (tracked === void 0) {
781
+ tracked = await store.trackedPaths(sessionId);
782
+ trackedBySession.set(sessionId, tracked);
783
+ }
784
+ if (tracked.size === 0) return;
785
+ let states = statesBySession.get(sessionId);
786
+ if (states === void 0) {
787
+ states = /* @__PURE__ */ new Map();
788
+ statesBySession.set(sessionId, states);
789
+ }
790
+ await reconcileTracked(store, sessionId, event.seq, tracked, states);
791
+ } catch (error) {
792
+ ctx.logger.warn(`[dsh-rewind] boundary re-check failed: ${error instanceof Error ? error.message : String(error)}`);
793
+ }
794
+ })();
795
+ }, { global: true });
548
796
  ctx.inject(["fs"], (scope) => {
549
797
  const fs = scope.fs;
798
+ fsService = fs;
550
799
  scope.on("tools/execute", async (exec, next) => {
551
800
  try {
552
801
  await captureBefore(fs, exec, pending);
@@ -557,7 +806,7 @@ function apply(ctx, config) {
557
806
  });
558
807
  scope.on("tools/post-execute", async (exec, result, next) => {
559
808
  try {
560
- await commitEntry(store, pending, anchorCache, exec, result);
809
+ await commitEntry(store, pending, anchorCache, trackedBySession, exec, result);
561
810
  } catch (error) {
562
811
  ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
563
812
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Pure candidate computation for the `/rewind` command decoration: which user
3
+ * messages the harness's popupSelect shell offers, withdrawn-row exclusion,
4
+ * preview truncation, and the mapping to popupSelect rows. The listing is a
5
+ * pure function of the session chat snapshot (`rewindCandidatesOf`) so it
6
+ * stays unit-testable in a node environment. Surface user/steering messages
7
+ * only, withdrawn (hidden) rows excluded, newest first — the top row is the
8
+ * default highlight, i.e. the most recent message and the most common rewind
9
+ * target.
10
+ *
11
+ * @module dsh-rewind/client/candidates
12
+ */
13
+ import type { SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client';
14
+ import type { RewindKey } from './locales.ts';
15
+ type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
16
+ /** Preview length cap for candidate rows (matches the host's candidate list). */
17
+ export declare const PREVIEW_CHARS = 80;
18
+ /** One selectable rewind target. */
19
+ export interface RewindCandidate {
20
+ /** Absolute log seq of the `user/message` event. */
21
+ readonly seq: number;
22
+ /** Unix epoch ms of the event. */
23
+ readonly time: number;
24
+ /** Truncated plain-text preview of the message content. */
25
+ readonly preview: string;
26
+ }
27
+ /** A chat snapshot subset the candidate listing reads (structural). */
28
+ export interface CandidateChat {
29
+ readonly order: readonly string[];
30
+ readonly nodes: {
31
+ get(key: string): CandidateUserNode | undefined;
32
+ };
33
+ }
34
+ /** A user/steering row subset; only fields the listing reads are real. */
35
+ export interface CandidateUserNode {
36
+ readonly kind: string;
37
+ readonly anchorSeq?: number;
38
+ readonly data: {
39
+ readonly seq: number;
40
+ readonly time: number;
41
+ readonly content: readonly {
42
+ type: string;
43
+ text?: unknown;
44
+ }[];
45
+ };
46
+ }
47
+ /** Join the text blocks of a user message into one plain preview. */
48
+ export declare function messagePreviewOf(message: {
49
+ readonly content: readonly {
50
+ type: string;
51
+ text?: unknown;
52
+ }[];
53
+ }): string;
54
+ /** Format a candidate row's clock time (`HH:MM`), matching the host format. */
55
+ export declare function formatCandidateTime(time: number): string;
56
+ /**
57
+ * List the selectable rewind candidates of a session chat snapshot: user and
58
+ * steering rows still on the surface (not hidden by a previous rewind), the
59
+ * newest `limit` kept, newest first — the top row is the default highlight,
60
+ * i.e. the most recent message and the most common rewind target.
61
+ * @param snap - the session chat snapshot.
62
+ * @param hidden - anchor seqs withdrawn by rewinds (from `hiddenSeqsOf`).
63
+ * @param limit - maximum number of candidates to return.
64
+ */
65
+ export declare function rewindCandidatesOf(snap: CandidateChat, hidden: ReadonlySet<number>, limit?: number): RewindCandidate[];
66
+ /** The candidates of a live chat snapshot, withdrawn rows already excluded. */
67
+ export declare function rewindCandidatesOfChat(snap: CandidateChat): RewindCandidate[];
68
+ /**
69
+ * Map the candidates to popupSelect rows: the message preview as the row
70
+ * label (left) and the clock time as the detail (right) — the shell's native
71
+ * label/detail flex layout, with no recency numbers.
72
+ */
73
+ export declare function rewindOptionsOf(snap: CandidateChat, t: Translate): SelectOption[];
74
+ /** Resolve one candidate by log seq (the mode popover's re-entry after a pick). */
75
+ export declare function candidateBySeq(snap: CandidateChat, seq: number): RewindCandidate | undefined;
76
+ export {};