dsh-rewind-plugin 0.2.0 → 0.2.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.
@@ -0,0 +1,34 @@
1
+ # DeepSeek Harness interface reference
2
+
3
+ > Maintainer doc: the harness subsystems this plugin depends on, and the key
4
+ > source files behind each interface. Local fork (if present):
5
+ > `<workspace>/oss/deepseek-harness/` — official repo:
6
+ > [github.com/deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)
7
+
8
+ ## Subsystem docs (`docs/subsystems/`)
9
+
10
+ - [session.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/session.md) — `Session` / `SessionStore` / event model (`Session.append`, `surfaceOp`, `sourceEventSeqs`)
11
+ - [core.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/core.md) — `Agent` (`status`, `session`) and other core types
12
+ - [commands.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/commands.md) — command registration (`ctx.commands.register`, `CommandInvocation`, `CommandResult`)
13
+ - [tools.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/tools.md) — tool execution seam (`tools/pre-execute` / `tools/post-execute`, `ToolExecution`)
14
+ - [session-query.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/session-query.md) — session query / `foldSurface` read-only interfaces
15
+
16
+ Also under `docs/` at the repo root: `persistence-catalog.md` (full
17
+ `SessionEventMap`), `tool-catalog.md` (tool inventory), `config-catalog.md`
18
+ (configuration inventory).
19
+
20
+ ## Key source (`packages/`)
21
+
22
+ | Interface | File |
23
+ |---|---|
24
+ | `Session.append`, surface validation | [packages/core/session/src/index.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/core/session/src/index.ts) |
25
+ | `foldSurface`, replacement rules | [packages/core/session/src/surface.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/core/session/src/surface.ts) |
26
+ | `SessionEventMap`, `SurfaceOp` | [packages/core/session/src/types.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/core/session/src/types.ts) |
27
+ | `createUserMessage`, `MessageSource` | [packages/llm/llm/src/message.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/llm/llm/src/message.ts) |
28
+ | `CommandDefinition`, `CommandInvocation` | [packages/interaction/commands/src/index.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/interaction/commands/src/index.ts) |
29
+ | `Agent` (`status` / `session`) | [packages/core/agent/src/runtime-types.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/core/agent/src/runtime-types.ts) |
30
+ | `tools/pre-execute` / `execute` / `post-execute` | [packages/core/tools/src/index.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/core/tools/src/index.ts) |
31
+ | Client DOM anchors (`data-chat-flow-kind` / `data-chat-anchor-key`) | [packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) |
32
+ | User bubble rendering | [packages/client/ui-conversation/src/client/chat/MessageItem.tsx](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/client/ui-conversation/src/client/chat/MessageItem.tsx) |
33
+ | Client `SessionFace` (`command` / `cancel`) | [packages/client/runtime/src/client/contract/session.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/client/runtime/src/client/contract/session.ts) |
34
+ | Client `PendingWait` (`respond`) | [packages/client/runtime/src/client/sessions/pending.ts](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/client/runtime/src/client/sessions/pending.ts) |
package/lib/client.js CHANGED
@@ -32,16 +32,61 @@ __export(index_exports, {
32
32
  });
33
33
  module.exports = __toCommonJS(index_exports);
34
34
 
35
+ // src/client/hidden.ts
36
+ function targetOfOutcome(text) {
37
+ if (text === void 0) return void 0;
38
+ const match = text.match(/seq (\d+)/);
39
+ return match !== null ? Number(match[1]) : void 0;
40
+ }
41
+ function isPreviewCommand(command) {
42
+ return (command.args ?? "").includes("preview");
43
+ }
44
+ function hiddenSeqsOf(snap) {
45
+ const hidden = /* @__PURE__ */ new Set();
46
+ let minTarget = Number.POSITIVE_INFINITY;
47
+ let maxMarker = Number.NEGATIVE_INFINITY;
48
+ for (const key of snap.order) {
49
+ const node = snap.nodes.get(key);
50
+ if (node === void 0 || node.kind !== "command") continue;
51
+ const command = node.data;
52
+ if (command.name !== "rewind") continue;
53
+ if (isPreviewCommand(command)) {
54
+ hidden.add(command.seq);
55
+ continue;
56
+ }
57
+ if (command.outcome?.kind !== "success") continue;
58
+ const marker = command.outcome.sourceEventSeq;
59
+ if (marker === void 0) continue;
60
+ hidden.add(command.seq);
61
+ const target = targetOfOutcome(command.outcome.text);
62
+ if (target !== void 0) {
63
+ if (target < minTarget) minTarget = target;
64
+ if (marker > maxMarker) maxMarker = marker;
65
+ }
66
+ }
67
+ if (Number.isFinite(minTarget)) {
68
+ for (const key of snap.order) {
69
+ const node = snap.nodes.get(key);
70
+ if (node === void 0) continue;
71
+ const anchor = node.anchorSeq;
72
+ if (anchor >= minTarget && anchor <= maxMarker) hidden.add(anchor);
73
+ }
74
+ }
75
+ return hidden;
76
+ }
77
+
35
78
  // src/client/locales.ts
36
79
  var zh = {
37
80
  "button.aria": "\u56DE\u9000\u5230\u6B64\u6D88\u606F",
38
81
  "button.title": "\u56DE\u9000",
39
82
  "popover.title": "\u56DE\u9000\u5230\u8FD9\u6761\u6D88\u606F",
40
- "popover.target": "seq {seq} \xB7 {time}",
83
+ "popover.noText": "\uFF08\u65E0\u6587\u672C\uFF09",
41
84
  "popover.chat": "\u4EC5\u56DE\u9000\u5BF9\u8BDD",
42
85
  "popover.chat.hint": "\u53EA\u56DE\u9000\u6A21\u578B\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u52A8\u5DE5\u4F5C\u533A\u6587\u4EF6",
43
86
  "popover.both": "\u56DE\u9000\u5BF9\u8BDD\u548C\u4EE3\u7801",
44
87
  "popover.both.hint": "\u5BF9\u8BDD\u56DE\u9000\u5E76\u8FD8\u539F\u5DE5\u4F5C\u533A\u6587\u4EF6",
88
+ "popover.checking": "\u6B63\u5728\u68C0\u67E5\u6587\u4EF6\u53D8\u66F4\u2026",
89
+ "popover.noChanges": "\u6B64\u6D88\u606F\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u6587\u4EF6\u53D8\u66F4\uFF0C\u4EC5\u53EF\u56DE\u9000\u5BF9\u8BDD",
45
90
  "popover.cancel": "\u53D6\u6D88",
46
91
  "popover.impact.loading": "\u6B63\u5728\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\u2026",
47
92
  "popover.impact.failed": "\u65E0\u6CD5\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\uFF1A{message}",
@@ -54,11 +99,13 @@ var en = {
54
99
  "button.aria": "Rewind to this message",
55
100
  "button.title": "Rewind",
56
101
  "popover.title": "Rewind to this message",
57
- "popover.target": "seq {seq} \xB7 {time}",
102
+ "popover.noText": "(no text)",
58
103
  "popover.chat": "Rewind conversation only",
59
104
  "popover.chat.hint": "Cut the model context only; workspace files stay untouched",
60
105
  "popover.both": "Rewind conversation and code",
61
106
  "popover.both.hint": "Cut the context and restore workspace files",
107
+ "popover.checking": "Checking for file changes\u2026",
108
+ "popover.noChanges": "No tracked file changes after this message; conversation-only rewind",
62
109
  "popover.cancel": "Cancel",
63
110
  "popover.impact.loading": "Fetching impact list\u2026",
64
111
  "popover.impact.failed": "Could not fetch the impact list: {message}",
@@ -114,9 +161,10 @@ var STYLE = `
114
161
  z-index: 1000;
115
162
  width: 288px;
116
163
  padding: 12px;
164
+ border: 1px solid var(--dsw-alias-border-l2);
117
165
  border-radius: 12px;
118
- background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));
119
- box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);
166
+ background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));
167
+ box-shadow: var(--dsw-shadow-lv3);
120
168
  font-size: 14px;
121
169
  line-height: 20px;
122
170
  color: var(--dsw-alias-label-primary);
@@ -151,6 +199,10 @@ var STYLE = `
151
199
  .dsh-rewind-popover-option:hover {
152
200
  background: var(--dsw-alias-interactive-bg-hover);
153
201
  }
202
+ .dsh-rewind-popover-option:disabled {
203
+ opacity: 0.5;
204
+ cursor: default;
205
+ }
154
206
  .dsh-rewind-popover-option-label {
155
207
  font-weight: 500;
156
208
  }
@@ -187,8 +239,11 @@ var STYLE = `
187
239
  cursor: pointer;
188
240
  }
189
241
  .dsh-rewind-popover-primary {
190
- background: var(--dsw-alias-accent, var(--dsw-accent, #5b8cff));
191
- color: var(--dsw-alias-on-accent, #fff);
242
+ background: var(--dsw-alias-button-primary-fill);
243
+ color: var(--dsw-alias-label-primary-foreground);
244
+ }
245
+ .dsh-rewind-popover-primary:hover:not(:disabled) {
246
+ background: var(--dsw-alias-button-primary-hover);
192
247
  }
193
248
  .dsh-rewind-popover-primary:disabled {
194
249
  opacity: 0.5;
@@ -207,9 +262,10 @@ var STYLE = `
207
262
  z-index: 1000;
208
263
  max-width: min(440px, calc(100vw - 24px));
209
264
  padding: 8px 12px;
265
+ border: 1px solid var(--dsw-alias-border-l2);
210
266
  border-radius: 10px;
211
- background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));
212
- box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);
267
+ background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));
268
+ box-shadow: var(--dsw-shadow-lv3);
213
269
  font-size: 13px;
214
270
  line-height: 18px;
215
271
  color: var(--dsw-alias-label-primary);
@@ -230,11 +286,11 @@ function closePopover() {
230
286
  disposeOutside = null;
231
287
  }
232
288
  }
233
- function formatTarget(seq, time, preview) {
289
+ function formatTarget(t, seq, time, preview) {
234
290
  const d = new Date(time);
235
291
  const hh = String(d.getHours()).padStart(2, "0");
236
292
  const mm = String(d.getMinutes()).padStart(2, "0");
237
- const previewText = preview.length > 0 ? preview : "(no text)";
293
+ const previewText = preview.length > 0 ? preview : t("popover.noText");
238
294
  return `seq ${seq} \xB7 ${hh}:${mm} \xB7 ${previewText}`;
239
295
  }
240
296
  function findCommand(snapshot, match) {
@@ -269,6 +325,15 @@ function waitForCommand(session, match, timeoutMs = 8e3) {
269
325
  check();
270
326
  });
271
327
  }
328
+ function isPreviewFor(node, seq) {
329
+ const args = node.args ?? "";
330
+ return node.name === "rewind" && args.includes("preview") && new RegExp(`(?:^|\\s)@${seq}(?=\\s|$)`).test(args);
331
+ }
332
+ async function previewImpact(session, seq) {
333
+ const result = await session.command(`/rewind preview @${seq} both`);
334
+ if (!result.ok || result.value?.matched !== true) return null;
335
+ return waitForCommand(session, (node) => isPreviewFor(node, seq));
336
+ }
272
337
  function el(tag, className, text) {
273
338
  const node = document.createElement(tag);
274
339
  node.className = className;
@@ -285,7 +350,7 @@ function modeOption(label, hint, onClick) {
285
350
  button.addEventListener("click", onClick);
286
351
  return button;
287
352
  }
288
- function renderImpactStep(root, opts, back) {
353
+ function renderImpactStep(root, opts, back, cached) {
289
354
  const { session, seq, t } = opts;
290
355
  const impact = el("div", CLASS.popoverImpact, t("popover.impact.loading"));
291
356
  const actions = el("div", CLASS.popoverActions);
@@ -302,24 +367,10 @@ function renderImpactStep(root, opts, back) {
302
367
  confirm.disabled = true;
303
368
  actions.append(confirm);
304
369
  root.replaceChildren(impact, actions);
305
- let outcome = null;
306
370
  void (async () => {
307
- const result = await session.command(`/rewind preview @${seq} both`);
308
- if (!result.ok || result.value?.matched !== true) {
309
- impact.textContent = t("popover.impact.failed", {
310
- message: result.ok ? "command not matched" : result.error?.message ?? "unknown error"
311
- });
312
- return;
313
- }
314
- outcome = await waitForCommand(
315
- session,
316
- (node) => {
317
- const args = node.args ?? "";
318
- return node.name === "rewind" && args.includes("preview") && new RegExp(`(?:^|\\s)@${seq}(?=\\s|$)`).test(args);
319
- }
320
- );
371
+ const outcome = cached ?? await previewImpact(session, seq);
321
372
  if (outcome === null) {
322
- impact.textContent = t("popover.impact.failed", { message: "timeout" });
373
+ impact.textContent = t("popover.impact.failed", { message: "preview command failed or timed out" });
323
374
  return;
324
375
  }
325
376
  if (outcome.kind === "error") {
@@ -342,41 +393,65 @@ function openPopover(opts) {
342
393
  const root = el("div", CLASS.popover);
343
394
  root.setAttribute("role", "dialog");
344
395
  root.setAttribute("aria-label", t("popover.title"));
345
- root.append(
346
- el("div", CLASS.popoverTitle, t("popover.title")),
347
- el("div", CLASS.popoverTarget, formatTarget(seq, time, preview))
348
- );
396
+ let bothState = { state: "loading" };
397
+ let impactOutcome = null;
349
398
  const renderModes = () => {
350
- root.replaceChildren(
399
+ const children = [
351
400
  el("div", CLASS.popoverTitle, t("popover.title")),
352
- el("div", CLASS.popoverTarget, formatTarget(seq, time, preview)),
401
+ el("div", CLASS.popoverTarget, formatTarget(t, seq, time, preview)),
353
402
  modeOption(t("popover.chat"), t("popover.chat.hint"), () => {
354
403
  closePopover();
355
404
  void session.command(`/rewind @${seq} chat`);
356
- }),
357
- modeOption(t("popover.both"), t("popover.both.hint"), () => {
358
- renderImpactStep(root, opts, renderModes);
359
- }),
360
- (() => {
361
- const actions = el("div", CLASS.popoverActions);
362
- const cancel = document.createElement("button");
363
- cancel.type = "button";
364
- cancel.className = CLASS.popoverGhost;
365
- cancel.textContent = t("popover.cancel");
366
- cancel.addEventListener("click", closePopover);
367
- actions.append(cancel);
368
- return actions;
369
- })()
370
- );
405
+ })
406
+ ];
407
+ if (bothState.state === "noChanges") {
408
+ children.push(el("div", CLASS.popoverImpact, t("popover.noChanges")));
409
+ } else {
410
+ const option = modeOption(
411
+ t("popover.both"),
412
+ bothState.state === "loading" ? t("popover.checking") : t("popover.both.hint"),
413
+ () => {
414
+ renderImpactStep(root, opts, renderModes, impactOutcome);
415
+ }
416
+ );
417
+ if (bothState.state === "loading") option.disabled = true;
418
+ children.push(option);
419
+ }
420
+ const actions = el("div", CLASS.popoverActions);
421
+ const cancel = document.createElement("button");
422
+ cancel.type = "button";
423
+ cancel.className = CLASS.popoverGhost;
424
+ cancel.textContent = t("popover.cancel");
425
+ cancel.addEventListener("click", closePopover);
426
+ actions.append(cancel);
427
+ children.push(actions);
428
+ root.replaceChildren(...children);
429
+ };
430
+ const position = () => {
431
+ const rect = anchor.getBoundingClientRect();
432
+ const gap = 4;
433
+ const height = root.offsetHeight;
434
+ const top = rect.bottom + gap + height <= window.innerHeight - 8 ? rect.bottom + gap : Math.max(8, rect.top - gap - height);
435
+ root.style.top = `${Math.round(top)}px`;
436
+ root.style.left = `${Math.round(Math.min(rect.right, window.innerWidth - 8 - root.offsetWidth))}px`;
371
437
  };
372
438
  renderModes();
373
439
  document.body.append(root);
374
- const rect = anchor.getBoundingClientRect();
375
- const gap = 4;
376
- const height = root.offsetHeight;
377
- const top = rect.bottom + gap + height <= window.innerHeight - 8 ? rect.bottom + gap : Math.max(8, rect.top - gap - height);
378
- root.style.top = `${Math.round(top)}px`;
379
- root.style.left = `${Math.round(Math.min(rect.right, window.innerWidth - 8 - root.offsetWidth))}px`;
440
+ position();
441
+ const hasFileImpact = (text) => text === void 0 || text.includes("\u5C06\u5F71\u54CD");
442
+ void (async () => {
443
+ const outcome = await previewImpact(session, seq);
444
+ impactOutcome = outcome;
445
+ if (outcome !== null && outcome.kind === "success") {
446
+ bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
447
+ }
448
+ renderModes();
449
+ position();
450
+ })().catch(() => {
451
+ bothState = { state: "hasChanges" };
452
+ renderModes();
453
+ position();
454
+ });
380
455
  popoverEl = root;
381
456
  const onPointerDown = (event) => {
382
457
  const target = event.target;
@@ -405,41 +480,9 @@ var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"]';
405
480
  var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
406
481
  var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
407
482
  var COMPOSER_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
408
- function targetOfOutcome(text) {
409
- if (text === void 0) return void 0;
410
- const match = text.match(/seq (\d+)/);
411
- return match !== null ? Number(match[1]) : void 0;
412
- }
413
483
  function messagePreviewOf(node) {
414
- return node.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim().slice(0, 80);
415
- }
416
- function hiddenSeqsOf(session) {
417
- const hidden = /* @__PURE__ */ new Set();
418
- const snap = session.getSnapshot();
419
- let latest = null;
420
- for (const key of snap.chat.order) {
421
- const node = snap.chat.nodes.get(key);
422
- if (node === void 0 || node.kind !== "command") continue;
423
- const command = node.data;
424
- if (command.name !== "rewind") continue;
425
- if (command.outcome?.kind !== "success") continue;
426
- if (command.outcome.sourceEventSeq === void 0) continue;
427
- hidden.add(command.seq);
428
- const marker = command.outcome.sourceEventSeq;
429
- const target = targetOfOutcome(command.outcome.text);
430
- if (target !== void 0 && (latest === null || marker > latest.marker)) {
431
- latest = { marker, target };
432
- }
433
- }
434
- if (latest !== null) {
435
- for (const key of snap.chat.order) {
436
- const node = snap.chat.nodes.get(key);
437
- if (node === void 0) continue;
438
- const anchor = node.anchorSeq;
439
- if (anchor >= latest.target && anchor <= latest.marker) hidden.add(anchor);
440
- }
441
- }
442
- return hidden;
484
+ const text = node.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
485
+ return text.length <= 80 ? text : `${text.slice(0, 79)}\u2026`;
443
486
  }
444
487
  function userTextAt(session, seq) {
445
488
  const snap = session.getSnapshot();
@@ -474,6 +517,13 @@ function apply(ctx) {
474
517
  const hidden = /* @__PURE__ */ new WeakSet();
475
518
  const buttons = /* @__PURE__ */ new Map();
476
519
  let observer = null;
520
+ const refreshButtonLabels = () => {
521
+ for (const button of buttons.values()) {
522
+ button.setAttribute("aria-label", t("button.aria"));
523
+ button.title = t("button.title");
524
+ }
525
+ };
526
+ const unsubscribeLocale = ctx.locale.subscribe(refreshButtonLabels);
477
527
  const sessionFor = () => {
478
528
  const sessionId = ctx.sessions.list.getSnapshot().current;
479
529
  if (sessionId === void 0) return void 0;
@@ -524,7 +574,7 @@ function apply(ctx) {
524
574
  if (!button.isConnected) buttons.delete(key);
525
575
  }
526
576
  const session = sessionFor();
527
- const hiddenSeqs = session !== void 0 ? hiddenSeqsOf(session) : /* @__PURE__ */ new Set();
577
+ const hiddenSeqs = session !== void 0 ? hiddenSeqsOf(session.getSnapshot().chat) : /* @__PURE__ */ new Set();
528
578
  let hiddenCount = 0;
529
579
  for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
530
580
  const key = seat.dataset.chatAnchorKey;
@@ -621,6 +671,7 @@ function apply(ctx) {
621
671
  observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
622
672
  scan();
623
673
  yield () => {
674
+ unsubscribeLocale();
624
675
  document.removeEventListener("keydown", onKeyDownGuard, true);
625
676
  document.removeEventListener("click", onClickGuard, true);
626
677
  if (guardHintEl !== null) guardHintEl.remove();
package/lib/index.js CHANGED
@@ -96,8 +96,8 @@ function execSessionCwd(exec, requestedPath) {
96
96
  }
97
97
 
98
98
  // src/snapshot.ts
99
- import { mkdir, readFile, readdir, rm, writeFile, lstat } from "node:fs/promises";
100
- import { join } from "node:path";
99
+ import { lstat, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
100
+ import { dirname, join } from "node:path";
101
101
  import { homedir } from "node:os";
102
102
  var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
103
103
  var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
@@ -105,6 +105,10 @@ var MAX_ANCHOR_GROUPS = 100;
105
105
  function safeFileId(callId) {
106
106
  return callId.replace(/[^a-zA-Z0-9._-]/g, "_");
107
107
  }
108
+ function safeSessionId(sessionId) {
109
+ const safe = sessionId.replace(/[^a-zA-Z0-9._-]/g, "_");
110
+ return safe === ".." || safe === "." ? "session" : safe;
111
+ }
108
112
  async function readEntry(file) {
109
113
  try {
110
114
  const parsed = JSON.parse(await readFile(file, "utf8"));
@@ -120,21 +124,29 @@ async function readEntry(file) {
120
124
  return void 0;
121
125
  }
122
126
  }
123
- async function isSymbolicLink(path) {
127
+ async function isLinkPath(path) {
124
128
  try {
125
- return (await lstat(path)).isSymbolicLink();
129
+ const stat2 = await lstat(path);
130
+ return stat2.isSymbolicLink() || stat2.nlink > 1;
126
131
  } catch {
127
132
  return false;
128
133
  }
129
134
  }
130
- var SnapshotStore = class {
135
+ var SnapshotStore = class _SnapshotStore {
131
136
  constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT) {
132
137
  this.root = root;
133
138
  }
134
139
  root;
140
+ /** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
141
+ static PRUNE_INTERVAL_MS = 1e3;
142
+ lastPruneAt = 0;
143
+ /** Absolute path of one session's snapshot directory (id sanitized). */
144
+ sessionDir(sessionId) {
145
+ return join(this.root, safeSessionId(sessionId));
146
+ }
135
147
  /** Absolute path of one anchor group directory. */
136
148
  anchorDir(sessionId, anchorSeq) {
137
- return join(this.root, sessionId, String(anchorSeq));
149
+ return join(this.sessionDir(sessionId), String(anchorSeq));
138
150
  }
139
151
  /** Commit one before-backup under its turn's anchor group. */
140
152
  async recordEntry(sessionId, entry) {
@@ -142,7 +154,11 @@ var SnapshotStore = class {
142
154
  await mkdir(dir, { recursive: true });
143
155
  const committed = { ...entry, time: Date.now() };
144
156
  await writeFile(join(dir, `${safeFileId(entry.callId)}.json`), JSON.stringify(committed), "utf8");
145
- await this.prune(sessionId);
157
+ const now = Date.now();
158
+ if (now - this.lastPruneAt >= _SnapshotStore.PRUNE_INTERVAL_MS) {
159
+ this.lastPruneAt = now;
160
+ await this.prune(sessionId);
161
+ }
146
162
  }
147
163
  /**
148
164
  * All committed entries anchored at or after `targetSeq`, newest first (for
@@ -152,7 +168,7 @@ var SnapshotStore = class {
152
168
  * earlier messages survive.
153
169
  */
154
170
  async entriesAfter(sessionId, targetSeq) {
155
- const sessionDir = join(this.root, sessionId);
171
+ const sessionDir = this.sessionDir(sessionId);
156
172
  let names;
157
173
  try {
158
174
  names = await readdir(sessionDir);
@@ -173,8 +189,11 @@ var SnapshotStore = class {
173
189
  }
174
190
  return entries.sort((a, b) => b.anchorSeq - a.anchorSeq || b.time - a.time);
175
191
  }
176
- /** Per-file restore impact for the earliest entry at/after the target. */
177
- async impactsAfter(sessionId, targetSeq) {
192
+ /**
193
+ * Per-path EARLIEST committed entry anchored at or after the target — the
194
+ * single source of truth for both restore and impact preview.
195
+ */
196
+ async earliestEntries(sessionId, targetSeq) {
178
197
  const earliest = /* @__PURE__ */ new Map();
179
198
  for (const entry of await this.entriesAfter(sessionId, targetSeq)) {
180
199
  const current = earliest.get(entry.path);
@@ -182,7 +201,11 @@ var SnapshotStore = class {
182
201
  earliest.set(entry.path, entry);
183
202
  }
184
203
  }
185
- return [...earliest.values()].sort((a, b) => a.path.localeCompare(b.path)).map((entry) => ({
204
+ return earliest;
205
+ }
206
+ /** Per-file restore impact for the earliest entry at/after the target. */
207
+ async impactsAfter(sessionId, targetSeq) {
208
+ return [...(await this.earliestEntries(sessionId, targetSeq)).values()].sort((a, b) => a.path.localeCompare(b.path)).map((entry) => ({
186
209
  path: entry.path,
187
210
  action: entry.before === null ? "delete" : "restore"
188
211
  }));
@@ -191,24 +214,19 @@ var SnapshotStore = class {
191
214
  * Restore the workspace to the target message's checkpoint: for every path
192
215
  * with entries anchored at or after it, apply the EARLIEST entry — write the
193
216
  * 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.
217
+ * target. Symlinked and hard-linked paths are skipped (reported, never
218
+ * written through); a restored file's parent directory is created when it
219
+ * was deleted after the backup. Failures are per-file and never abort the
220
+ * pass.
196
221
  */
197
222
  async restoreAfter(sessionId, targetSeq, deleteFile) {
198
223
  const restored = [];
199
224
  const deleted = [];
200
225
  const skipped = [];
201
226
  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()) {
227
+ for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
210
228
  try {
211
- if (await isSymbolicLink(entry.path)) {
229
+ if (await isLinkPath(entry.path)) {
212
230
  skipped.push(entry.path);
213
231
  continue;
214
232
  }
@@ -216,6 +234,7 @@ var SnapshotStore = class {
216
234
  await deleteFile(entry.path);
217
235
  deleted.push(entry.path);
218
236
  } else {
237
+ await mkdir(dirname(entry.path), { recursive: true });
219
238
  await writeFile(entry.path, entry.before, "utf8");
220
239
  restored.push(entry.path);
221
240
  }
@@ -230,7 +249,7 @@ var SnapshotStore = class {
230
249
  * {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
231
250
  */
232
251
  async prune(sessionId, keep = MAX_ANCHOR_GROUPS) {
233
- const sessionDir = join(this.root, sessionId);
252
+ const sessionDir = this.sessionDir(sessionId);
234
253
  let names;
235
254
  try {
236
255
  names = await readdir(sessionDir);
@@ -247,7 +266,6 @@ var SnapshotStore = class {
247
266
  }
248
267
  /** True when a path exists on disk (used by tests and diagnostics). */
249
268
  async exists(path) {
250
- const { stat } = await import("node:fs/promises");
251
269
  try {
252
270
  await stat(path);
253
271
  return true;
@@ -265,8 +283,9 @@ var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_edito
265
283
  var MUTATING_EDITOR_COMMANDS = /* @__PURE__ */ new Set(["create", "str_replace", "insert"]);
266
284
  var USAGE = [
267
285
  "Usage:",
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"
286
+ " /rewind \uFF08\u65E0\u53C2\u6570\uFF09\u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F",
287
+ " /rewind @<seq> chat|both \u56DE\u9000\u5230\u6307\u5B9A\u6D88\u606F\uFF08chat \u4EC5\u5BF9\u8BDD / both \u5BF9\u8BDD+\u6587\u4EF6\uFF09",
288
+ " \u624B\u52A8\u8F93\u5165 /rewind \u4F1A\u88AB\u62E6\u622A\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684\u300C\u56DE\u9000\u300D\u6309\u94AE"
270
289
  ].join("\n");
271
290
  function mutationPathOf(exec) {
272
291
  const args = exec.arguments;
@@ -279,12 +298,19 @@ function mutationPathOf(exec) {
279
298
  }
280
299
  return void 0;
281
300
  }
282
- function anchorSeqOf(session) {
283
- for (let i = session.events.length - 1; i >= 0; i--) {
284
- const event = session.events[i];
285
- if (event.type === "user/message") return event.seq;
301
+ function anchorSeqOf(session, cache) {
302
+ const events = session.events;
303
+ const cached = cache.get(session.id);
304
+ if (cached !== void 0 && cached.eventsLength === events.length) return cached.anchor;
305
+ let anchor = cached?.anchor;
306
+ for (let i = events.length - 1; i >= (cached?.eventsLength ?? 0); i--) {
307
+ if (events[i].type === "user/message") {
308
+ anchor = events[i].seq;
309
+ break;
310
+ }
286
311
  }
287
- return void 0;
312
+ cache.set(session.id, { anchor, eventsLength: events.length });
313
+ return anchor;
288
314
  }
289
315
  async function resolveTarget(fs, path, cwd, signal) {
290
316
  try {
@@ -315,7 +341,7 @@ async function captureBefore(fs, exec, pending) {
315
341
  const before = await readTextOrUndefined(fs, target, exec.signal);
316
342
  pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, before });
317
343
  }
318
- async function commitEntry(store, pending, exec, result) {
344
+ async function commitEntry(store, pending, anchorCache, exec, result) {
319
345
  const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
320
346
  const capture = pending.get(key);
321
347
  if (capture === void 0) return;
@@ -323,7 +349,7 @@ async function commitEntry(store, pending, exec, result) {
323
349
  if (result.isError) return;
324
350
  const agent = exec.agent;
325
351
  if (agent === void 0) return;
326
- const anchorSeq = anchorSeqOf(agent.session);
352
+ const anchorSeq = anchorSeqOf(agent.session, anchorCache);
327
353
  if (anchorSeq === void 0) return;
328
354
  await store.recordEntry(agent.session.id, {
329
355
  callId: exec.callId,
@@ -417,7 +443,7 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode) {
417
443
  const parts = [];
418
444
  if (outcome.restored.length > 0) parts.push(`\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6`);
419
445
  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`);
446
+ if (outcome.skipped.length > 0) parts.push(`\u8DF3\u8FC7 ${outcome.skipped.length} \u4E2A\u94FE\u63A5`);
421
447
  restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4";
422
448
  restore += renderFailures(outcome.failed);
423
449
  }
@@ -482,6 +508,7 @@ async function handleRewind(ctx, store, invocation) {
482
508
  function apply(ctx, config) {
483
509
  const store = new SnapshotStore(config?.snapshotDir);
484
510
  const pending = /* @__PURE__ */ new Map();
511
+ const anchorCache = /* @__PURE__ */ new Map();
485
512
  ctx.effect(function* () {
486
513
  yield ctx.commands.register({
487
514
  name: "rewind",
@@ -501,12 +528,16 @@ function apply(ctx, config) {
501
528
  });
502
529
  scope.on("tools/post-execute", async (exec, result, next) => {
503
530
  try {
504
- await commitEntry(store, pending, exec, result);
531
+ await commitEntry(store, pending, anchorCache, exec, result);
505
532
  } catch (error) {
506
533
  ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
507
534
  }
508
535
  return next();
509
536
  });
537
+ scope.on("tools/result", (exec) => {
538
+ pending.delete(`${exec.agent?.id ?? "anon"}:${exec.callId}`);
539
+ return void 0;
540
+ });
510
541
  });
511
542
  }
512
543
  export {