dsh-rewind-plugin 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,54 @@ __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 hiddenSeqsOf(snap) {
42
+ const hidden = /* @__PURE__ */ new Set();
43
+ let minTarget = Number.POSITIVE_INFINITY;
44
+ let maxMarker = Number.NEGATIVE_INFINITY;
45
+ for (const key of snap.order) {
46
+ const node = snap.nodes.get(key);
47
+ if (node === void 0 || node.kind !== "command") continue;
48
+ const command = node.data;
49
+ if (command.name !== "rewind") continue;
50
+ if (command.outcome?.kind !== "success") continue;
51
+ if (command.outcome.sourceEventSeq === void 0) continue;
52
+ hidden.add(command.seq);
53
+ const marker = command.outcome.sourceEventSeq;
54
+ const target = targetOfOutcome(command.outcome.text);
55
+ if (target !== void 0) {
56
+ if (target < minTarget) minTarget = target;
57
+ if (marker > maxMarker) maxMarker = marker;
58
+ }
59
+ }
60
+ if (Number.isFinite(minTarget)) {
61
+ for (const key of snap.order) {
62
+ const node = snap.nodes.get(key);
63
+ if (node === void 0) continue;
64
+ const anchor = node.anchorSeq;
65
+ if (anchor >= minTarget && anchor <= maxMarker) hidden.add(anchor);
66
+ }
67
+ }
68
+ return hidden;
69
+ }
70
+
35
71
  // src/client/locales.ts
36
72
  var zh = {
37
73
  "button.aria": "\u56DE\u9000\u5230\u6B64\u6D88\u606F",
38
74
  "button.title": "\u56DE\u9000",
39
75
  "popover.title": "\u56DE\u9000\u5230\u8FD9\u6761\u6D88\u606F",
40
- "popover.target": "seq {seq} \xB7 {time}",
76
+ "popover.noText": "\uFF08\u65E0\u6587\u672C\uFF09",
41
77
  "popover.chat": "\u4EC5\u56DE\u9000\u5BF9\u8BDD",
42
78
  "popover.chat.hint": "\u53EA\u56DE\u9000\u6A21\u578B\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u52A8\u5DE5\u4F5C\u533A\u6587\u4EF6",
43
79
  "popover.both": "\u56DE\u9000\u5BF9\u8BDD\u548C\u4EE3\u7801",
44
80
  "popover.both.hint": "\u5BF9\u8BDD\u56DE\u9000\u5E76\u8FD8\u539F\u5DE5\u4F5C\u533A\u6587\u4EF6",
81
+ "popover.checking": "\u6B63\u5728\u68C0\u67E5\u6587\u4EF6\u53D8\u66F4\u2026",
82
+ "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
83
  "popover.cancel": "\u53D6\u6D88",
46
84
  "popover.impact.loading": "\u6B63\u5728\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\u2026",
47
85
  "popover.impact.failed": "\u65E0\u6CD5\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\uFF1A{message}",
@@ -54,11 +92,13 @@ var en = {
54
92
  "button.aria": "Rewind to this message",
55
93
  "button.title": "Rewind",
56
94
  "popover.title": "Rewind to this message",
57
- "popover.target": "seq {seq} \xB7 {time}",
95
+ "popover.noText": "(no text)",
58
96
  "popover.chat": "Rewind conversation only",
59
97
  "popover.chat.hint": "Cut the model context only; workspace files stay untouched",
60
98
  "popover.both": "Rewind conversation and code",
61
99
  "popover.both.hint": "Cut the context and restore workspace files",
100
+ "popover.checking": "Checking for file changes\u2026",
101
+ "popover.noChanges": "No tracked file changes after this message; conversation-only rewind",
62
102
  "popover.cancel": "Cancel",
63
103
  "popover.impact.loading": "Fetching impact list\u2026",
64
104
  "popover.impact.failed": "Could not fetch the impact list: {message}",
@@ -114,9 +154,10 @@ var STYLE = `
114
154
  z-index: 1000;
115
155
  width: 288px;
116
156
  padding: 12px;
157
+ border: 1px solid var(--dsw-alias-border-l2);
117
158
  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);
159
+ background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));
160
+ box-shadow: var(--dsw-shadow-lv3);
120
161
  font-size: 14px;
121
162
  line-height: 20px;
122
163
  color: var(--dsw-alias-label-primary);
@@ -151,6 +192,10 @@ var STYLE = `
151
192
  .dsh-rewind-popover-option:hover {
152
193
  background: var(--dsw-alias-interactive-bg-hover);
153
194
  }
195
+ .dsh-rewind-popover-option:disabled {
196
+ opacity: 0.5;
197
+ cursor: default;
198
+ }
154
199
  .dsh-rewind-popover-option-label {
155
200
  font-weight: 500;
156
201
  }
@@ -187,8 +232,11 @@ var STYLE = `
187
232
  cursor: pointer;
188
233
  }
189
234
  .dsh-rewind-popover-primary {
190
- background: var(--dsw-alias-accent, var(--dsw-accent, #5b8cff));
191
- color: var(--dsw-alias-on-accent, #fff);
235
+ background: var(--dsw-alias-button-primary-fill);
236
+ color: var(--dsw-alias-label-primary-foreground);
237
+ }
238
+ .dsh-rewind-popover-primary:hover:not(:disabled) {
239
+ background: var(--dsw-alias-button-primary-hover);
192
240
  }
193
241
  .dsh-rewind-popover-primary:disabled {
194
242
  opacity: 0.5;
@@ -207,9 +255,10 @@ var STYLE = `
207
255
  z-index: 1000;
208
256
  max-width: min(440px, calc(100vw - 24px));
209
257
  padding: 8px 12px;
258
+ border: 1px solid var(--dsw-alias-border-l2);
210
259
  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);
260
+ background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));
261
+ box-shadow: var(--dsw-shadow-lv3);
213
262
  font-size: 13px;
214
263
  line-height: 18px;
215
264
  color: var(--dsw-alias-label-primary);
@@ -230,11 +279,11 @@ function closePopover() {
230
279
  disposeOutside = null;
231
280
  }
232
281
  }
233
- function formatTarget(seq, time, preview) {
282
+ function formatTarget(t, seq, time, preview) {
234
283
  const d = new Date(time);
235
284
  const hh = String(d.getHours()).padStart(2, "0");
236
285
  const mm = String(d.getMinutes()).padStart(2, "0");
237
- const previewText = preview.length > 0 ? preview : "(no text)";
286
+ const previewText = preview.length > 0 ? preview : t("popover.noText");
238
287
  return `seq ${seq} \xB7 ${hh}:${mm} \xB7 ${previewText}`;
239
288
  }
240
289
  function findCommand(snapshot, match) {
@@ -269,6 +318,15 @@ function waitForCommand(session, match, timeoutMs = 8e3) {
269
318
  check();
270
319
  });
271
320
  }
321
+ function isPreviewFor(node, seq) {
322
+ const args = node.args ?? "";
323
+ return node.name === "rewind" && args.includes("preview") && new RegExp(`(?:^|\\s)@${seq}(?=\\s|$)`).test(args);
324
+ }
325
+ async function previewImpact(session, seq) {
326
+ const result = await session.command(`/rewind preview @${seq} both`);
327
+ if (!result.ok || result.value?.matched !== true) return null;
328
+ return waitForCommand(session, (node) => isPreviewFor(node, seq));
329
+ }
272
330
  function el(tag, className, text) {
273
331
  const node = document.createElement(tag);
274
332
  node.className = className;
@@ -285,7 +343,7 @@ function modeOption(label, hint, onClick) {
285
343
  button.addEventListener("click", onClick);
286
344
  return button;
287
345
  }
288
- function renderImpactStep(root, opts, back) {
346
+ function renderImpactStep(root, opts, back, cached) {
289
347
  const { session, seq, t } = opts;
290
348
  const impact = el("div", CLASS.popoverImpact, t("popover.impact.loading"));
291
349
  const actions = el("div", CLASS.popoverActions);
@@ -302,24 +360,10 @@ function renderImpactStep(root, opts, back) {
302
360
  confirm.disabled = true;
303
361
  actions.append(confirm);
304
362
  root.replaceChildren(impact, actions);
305
- let outcome = null;
306
363
  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
- );
364
+ const outcome = cached ?? await previewImpact(session, seq);
321
365
  if (outcome === null) {
322
- impact.textContent = t("popover.impact.failed", { message: "timeout" });
366
+ impact.textContent = t("popover.impact.failed", { message: "preview command failed or timed out" });
323
367
  return;
324
368
  }
325
369
  if (outcome.kind === "error") {
@@ -342,41 +386,65 @@ function openPopover(opts) {
342
386
  const root = el("div", CLASS.popover);
343
387
  root.setAttribute("role", "dialog");
344
388
  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
- );
389
+ let bothState = { state: "loading" };
390
+ let impactOutcome = null;
349
391
  const renderModes = () => {
350
- root.replaceChildren(
392
+ const children = [
351
393
  el("div", CLASS.popoverTitle, t("popover.title")),
352
- el("div", CLASS.popoverTarget, formatTarget(seq, time, preview)),
394
+ el("div", CLASS.popoverTarget, formatTarget(t, seq, time, preview)),
353
395
  modeOption(t("popover.chat"), t("popover.chat.hint"), () => {
354
396
  closePopover();
355
397
  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
- );
398
+ })
399
+ ];
400
+ if (bothState.state === "noChanges") {
401
+ children.push(el("div", CLASS.popoverImpact, t("popover.noChanges")));
402
+ } else {
403
+ const option = modeOption(
404
+ t("popover.both"),
405
+ bothState.state === "loading" ? t("popover.checking") : t("popover.both.hint"),
406
+ () => {
407
+ renderImpactStep(root, opts, renderModes, impactOutcome);
408
+ }
409
+ );
410
+ if (bothState.state === "loading") option.disabled = true;
411
+ children.push(option);
412
+ }
413
+ const actions = el("div", CLASS.popoverActions);
414
+ const cancel = document.createElement("button");
415
+ cancel.type = "button";
416
+ cancel.className = CLASS.popoverGhost;
417
+ cancel.textContent = t("popover.cancel");
418
+ cancel.addEventListener("click", closePopover);
419
+ actions.append(cancel);
420
+ children.push(actions);
421
+ root.replaceChildren(...children);
422
+ };
423
+ const position = () => {
424
+ const rect = anchor.getBoundingClientRect();
425
+ const gap = 4;
426
+ const height = root.offsetHeight;
427
+ const top = rect.bottom + gap + height <= window.innerHeight - 8 ? rect.bottom + gap : Math.max(8, rect.top - gap - height);
428
+ root.style.top = `${Math.round(top)}px`;
429
+ root.style.left = `${Math.round(Math.min(rect.right, window.innerWidth - 8 - root.offsetWidth))}px`;
371
430
  };
372
431
  renderModes();
373
432
  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`;
433
+ position();
434
+ const hasFileImpact = (text) => text === void 0 || text.includes("\u5C06\u5F71\u54CD");
435
+ void (async () => {
436
+ const outcome = await previewImpact(session, seq);
437
+ impactOutcome = outcome;
438
+ if (outcome !== null && outcome.kind === "success") {
439
+ bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
440
+ }
441
+ renderModes();
442
+ position();
443
+ })().catch(() => {
444
+ bothState = { state: "hasChanges" };
445
+ renderModes();
446
+ position();
447
+ });
380
448
  popoverEl = root;
381
449
  const onPointerDown = (event) => {
382
450
  const target = event.target;
@@ -405,41 +473,9 @@ var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"]';
405
473
  var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
406
474
  var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
407
475
  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
476
  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;
477
+ const text = node.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
478
+ return text.length <= 80 ? text : `${text.slice(0, 79)}\u2026`;
443
479
  }
444
480
  function userTextAt(session, seq) {
445
481
  const snap = session.getSnapshot();
@@ -474,6 +510,13 @@ function apply(ctx) {
474
510
  const hidden = /* @__PURE__ */ new WeakSet();
475
511
  const buttons = /* @__PURE__ */ new Map();
476
512
  let observer = null;
513
+ const refreshButtonLabels = () => {
514
+ for (const button of buttons.values()) {
515
+ button.setAttribute("aria-label", t("button.aria"));
516
+ button.title = t("button.title");
517
+ }
518
+ };
519
+ const unsubscribeLocale = ctx.locale.subscribe(refreshButtonLabels);
477
520
  const sessionFor = () => {
478
521
  const sessionId = ctx.sessions.list.getSnapshot().current;
479
522
  if (sessionId === void 0) return void 0;
@@ -524,7 +567,7 @@ function apply(ctx) {
524
567
  if (!button.isConnected) buttons.delete(key);
525
568
  }
526
569
  const session = sessionFor();
527
- const hiddenSeqs = session !== void 0 ? hiddenSeqsOf(session) : /* @__PURE__ */ new Set();
570
+ const hiddenSeqs = session !== void 0 ? hiddenSeqsOf(session.getSnapshot().chat) : /* @__PURE__ */ new Set();
528
571
  let hiddenCount = 0;
529
572
  for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
530
573
  const key = seat.dataset.chatAnchorKey;
@@ -621,6 +664,7 @@ function apply(ctx) {
621
664
  observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
622
665
  scan();
623
666
  yield () => {
667
+ unsubscribeLocale();
624
668
  document.removeEventListener("keydown", onKeyDownGuard, true);
625
669
  document.removeEventListener("click", onClickGuard, true);
626
670
  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 {