dsh-rewind-plugin 0.2.5 → 0.2.7

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/README.md CHANGED
@@ -4,7 +4,9 @@
4
4
 
5
5
  In-place conversation rewind for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): the Claude Code `/rewind` semantics inside the **same session window** — cut the model context back to an earlier user message, and optionally restore workspace files from **disk-persisted before-backups**.
6
6
 
7
- > **Status:** published to npm (`dsh-rewind-plugin`, v0.2.5) via GitHub Actions Trusted Publishing + Sigstore provenance. Targets the web profile (`dsh --profile web`). Interaction mirrors Claude Code's rewind, adapted to dsh's real web UI.
7
+ > **Status:** published to npm (`dsh-rewind-plugin`, v0.2.7) via GitHub Actions Trusted Publishing + Sigstore provenance. Targets the web profile (`dsh --profile web`). Interaction mirrors Claude Code's rewind, adapted to dsh's real web UI.
8
+ >
9
+ > **v0.2.7 highlights:** composer refill is event-driven (reopening a session never resurrects withdrawn text); subagent edits are not tracked (Claude Code alignment); concurrent rewinds are guarded; the code-restore option appears only when tracked changes exist (mixed host/client versions safe).
8
10
 
9
11
  [![npm version](https://img.shields.io/npm/v/dsh-rewind-plugin.svg)](https://www.npmjs.com/package/dsh-rewind-plugin)
10
12
  [![npm license](https://img.shields.io/npm/l/dsh-rewind-plugin.svg)](https://github.com/SiriLee/dsh-rewind/blob/main/LICENSE)
@@ -85,20 +87,26 @@ conversation**: the marker's turn number collided with the next real turn's
85
87
  `Failed to load history: conversation Context …:turn-tail… received an update before its start Match (internal)`
86
88
  and the history vanished. Rewinds created from 0.2.5 on no longer produce the
87
89
  collision, but **already-corrupted sessions need an offline repair** (the log is
88
- append-only — it cannot be rewritten in memory):
90
+ append-only — it cannot be rewritten in memory).
91
+
92
+ The repair tool ships **inside the npm package** (`dsh-rewind-repair`) — no
93
+ source checkout needed:
89
94
 
90
95
  ```sh
91
96
  # 1. Fully quit dsh web / host first (while a session is resident in memory,
92
97
  # a disk repair is overwritten by the next checkpoint)
93
- # 2. Run the offline repair script (scans every session under ~/.dsh/sessions,
98
+ # 2. Run the offline repair (scans every session under ~/.dsh/sessions,
94
99
  # rewriting each marker's turn back to the last started turn)
95
- node scripts/repair-markers.mjs # default: scan ~/.dsh/sessions
96
- node scripts/repair-markers.mjs --dry-run # report only, no writes
97
- node scripts/repair-markers.mjs --dir <sessions root> # custom data dir
100
+ npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair
101
+ npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair -- --dry-run # preview only
98
102
  # 3. Restart dsh web — the repaired sessions load their history again
99
103
  ```
100
104
 
101
- The script only rewrites the `data.turn` of `dsh-rewind` empty-marker events
105
+ Or install it globally once (`npm i -g dsh-rewind-plugin`) and run
106
+ `dsh-rewind-repair` directly; from a source checkout the same tool is
107
+ `node scripts/repair-markers.mjs` (identical flags).
108
+
109
+ The tool only rewrites the `data.turn` of `dsh-rewind` empty-marker events
102
110
  (keeping seqs, order, and the zstd frame structure intact), backs up the original
103
111
  file to `session.jsonl.zstd.bak-<timestamp>` before writing, and never touches
104
112
  any other event — safe to run repeatedly.
@@ -157,6 +165,7 @@ Rewinding to a message **withdraws** it and everything after it — the transcri
157
165
  ## Behavior details & limitations
158
166
 
159
167
  - Only **write-class tools** running while the plugin is active are tracked (`write` / `edit` / `str_replace_editor`). Changes made by `bash`, other tools, or external programs are not backed up and cannot be restored — the same limitation as Claude Code, which also defers such rollbacks to the user's git.
168
+ - **Subagent edits are not tracked** — same as Claude Code. A subagent runs its own session, so its backups could never be restored by a rewind of the parent session; the capture is skipped instead of recording to an unreachable store.
160
169
  - If a before-capture read fails (e.g. a permission error), that change is simply not backed up and a `both` rewind cannot restore it — the plugin logs a warning but **does not block the write**.
161
170
  - File restore/delete writes through the **real local filesystem**; under sandbox / remote backends path resolution may be restricted.
162
171
  - Symbolic links and hard links are not written through (they share the inode with another name; a restore would clobber both) — they are skipped and reported.
package/README.zh.md CHANGED
@@ -4,7 +4,9 @@
4
4
 
5
5
  [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)——把模型上下文剪回更早的一条用户消息,并可基于**落盘的写前备份**还原工作区文件。
6
6
 
7
- > **状态**:已发布 npm(`dsh-rewind-plugin`,v0.2.5),经 GitHub Actions Trusted Publishing + Sigstore provenance 构建发布。目标为 web 配置档(`dsh --profile web`)。交互以 Claude Code 的 rewind 为参考,并贴合 dsh Web 实际 UI。
7
+ > **状态**:已发布 npm(`dsh-rewind-plugin`,v0.2.7),经 GitHub Actions Trusted Publishing + Sigstore provenance 构建发布。目标为 web 配置档(`dsh --profile web`)。交互以 Claude Code 的 rewind 为参考,并贴合 dsh Web 实际 UI。
8
+ >
9
+ > **v0.2.7 要点**:输入框回填改为事件驱动(重开会话不再复活已撤回文本);子代理编辑不跟踪(对齐 Claude Code);并发回退有防护;「回退代码」选项仅在存在跟踪变更时显示(兼容新旧 host/client 混合版本)。
8
10
 
9
11
  [![npm version](https://img.shields.io/npm/v/dsh-rewind-plugin.svg)](https://www.npmjs.com/package/dsh-rewind-plugin)
10
12
  [![npm license](https://img.shields.io/npm/l/dsh-rewind-plugin.svg)](https://github.com/SiriLee/dsh-rewind/blob/main/LICENSE)
@@ -83,18 +85,22 @@
83
85
  与下一条真实回合的 `turn/start` 编号冲突,重新打开会话时界面报
84
86
  `历史加载失败:conversation Context …:turn-tail… received an update before its start Match(internal)`,
85
87
  历史整段消失。0.2.5 起新的回退不再产生该冲突;但**已损坏的会话需要离线修复**(日志是
86
- append-only 的,不能在内存中改写):
88
+ append-only 的,不能在内存中改写)。
89
+
90
+ 修复工具**已随 npm 包发布**(`dsh-rewind-repair`)——无需下载源码:
87
91
 
88
92
  ```sh
89
93
  # 1. 先完全退出 dsh web / host(会话处于驻留内存时,磁盘修复会被下次 checkpoint 覆盖)
90
- # 2. 运行离线修复脚本(扫描 ~/.dsh/sessions 下所有会话,把标记 turn 改回最后一个已开始的回合)
91
- node scripts/repair-markers.mjs # 默认扫描 ~/.dsh/sessions
92
- node scripts/repair-markers.mjs --dry-run # 只报告不写盘
93
- node scripts/repair-markers.mjs --dir <sessions 根目录> # 指定数据目录
94
+ # 2. 运行离线修复(扫描 ~/.dsh/sessions 下所有会话,把标记 turn 改回最后一个已开始的回合)
95
+ npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair
96
+ npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair -- --dry-run # 只预览不写盘
94
97
  # 3. 重启 dsh web,损坏的会话即可正常加载历史
95
98
  ```
96
99
 
97
- 脚本只改写 `dsh-rewind` 空标记事件的 `data.turn` 字段(保持 seq / 顺序 / 帧结构不变),
100
+ 也可以全局安装一次(`npm i -g dsh-rewind-plugin`)后直接运行 `dsh-rewind-repair`;
101
+ 源码方式为 `node scripts/repair-markers.mjs`(参数相同)。
102
+
103
+ 工具只改写 `dsh-rewind` 空标记事件的 `data.turn` 字段(保持 seq / 顺序 / zstd 帧结构不变),
98
104
  改前自动备份原文件为 `session.jsonl.zstd.bak-<时间戳>`;不改动任何其它事件,可安全重复运行。
99
105
 
100
106
  ## 📦 安装
@@ -150,6 +156,7 @@ dsh plugin --profile web add github:SiriLee/dsh-rewind#<commit-sha>
150
156
  ## 行为细节与限制
151
157
 
152
158
  - 只跟踪**插件运行期间、经写类工具**的变更(`write` / `edit` / `str_replace_editor`)。`bash`、其他工具或外部程序的修改不在备份内、无法还原——与 Claude Code 相同,官方同样不覆盖,此类回退交由用户 git 处理。
159
+ - **子代理(subagent)的编辑不跟踪**——与 Claude Code 相同。子代理运行在自己的会话里,其备份无法被父会话的回退还原;插件直接跳过捕获,而不是记录到永远读不到的位置。
153
160
  - 写前备份读取失败时(如权限错误)该次变更不会入备份,`both` 回退无法还原它——插件会在日志中警告,但**不会阻塞写操作**。
154
161
  - 文件还原/删除直写**真实本地文件系统**;sandbox / 远程 backend 下路径解析可能受限。
155
162
  - 符号链接与硬链接不写入(它们与另一名字共享 inode,还原会互相污染)——跳过并在结果中提示。
package/lib/client.js CHANGED
@@ -38,6 +38,18 @@ function targetOfOutcome(text) {
38
38
  const match = text.match(/seq (\d+)/);
39
39
  return match !== null ? Number(match[1]) : void 0;
40
40
  }
41
+ function isExecutedRewindCommand(node, seq) {
42
+ if (node.name !== "rewind" || node.outcome?.kind !== "success") return false;
43
+ if (node.outcome.sourceEventSeq === void 0) return false;
44
+ const args = node.args ?? "";
45
+ return new RegExp(`(?:^|\\s)@${seq}(?:\\s|$)`).test(args);
46
+ }
47
+ function hasFileImpact(text) {
48
+ if (text === void 0) return true;
49
+ const match = text.match(/impact=(\d+)/);
50
+ if (match !== null) return Number(match[1]) > 0;
51
+ return text.includes("\u5C06\u5F71\u54CD");
52
+ }
41
53
  function isPreviewCommand(command) {
42
54
  return (command.args ?? "").includes("preview");
43
55
  }
@@ -291,6 +303,9 @@ function formatTarget(t, seq, time, preview) {
291
303
  const previewText = preview.length > 0 ? preview : t("popover.noText");
292
304
  return `seq ${seq} \xB7 ${hh}:${mm} \xB7 ${previewText}`;
293
305
  }
306
+ function stripImpactToken(text) {
307
+ return text.replace(/\n?impact=\d+\s*$/, "");
308
+ }
294
309
  function findCommand(snapshot, match) {
295
310
  let found;
296
311
  for (const key of snapshot.chat.order) {
@@ -302,6 +317,18 @@ function findCommand(snapshot, match) {
302
317
  }
303
318
  return found;
304
319
  }
320
+ function knownCommandSeqs(session, match) {
321
+ const known = /* @__PURE__ */ new Set();
322
+ const snapshot = session.getSnapshot();
323
+ for (const key of snapshot.chat.order) {
324
+ const node = snapshot.chat.nodes.get(key);
325
+ if (node !== void 0 && node.kind === "command") {
326
+ const command = node.data;
327
+ if (match(command)) known.add(command.seq);
328
+ }
329
+ }
330
+ return known;
331
+ }
305
332
  function waitForCommand(session, match, timeoutMs = 8e3) {
306
333
  return new Promise((resolve) => {
307
334
  let settled = false;
@@ -328,9 +355,10 @@ function isPreviewFor(node, seq) {
328
355
  return node.name === "rewind" && args.includes("preview") && new RegExp(`(?:^|\\s)@${seq}(?=\\s|$)`).test(args);
329
356
  }
330
357
  async function previewImpact(session, seq) {
358
+ const known = knownCommandSeqs(session, (node) => isPreviewFor(node, seq));
331
359
  const result = await session.command(`/rewind preview @${seq} both`);
332
360
  if (!result.ok || result.value?.matched !== true) return null;
333
- return waitForCommand(session, (node) => isPreviewFor(node, seq));
361
+ return waitForCommand(session, (node) => isPreviewFor(node, seq) && !known.has(node.seq));
334
362
  }
335
363
  function el(tag, className, text) {
336
364
  const node = document.createElement(tag);
@@ -375,11 +403,11 @@ function renderImpactStep(root, opts, back, cached) {
375
403
  impact.textContent = t("popover.impact.failed", { message: outcome.text ?? "unknown error" });
376
404
  return;
377
405
  }
378
- impact.textContent = outcome.text ?? t("popover.impact.none");
406
+ impact.textContent = outcome.text === void 0 ? t("popover.impact.none") : stripImpactToken(outcome.text);
379
407
  confirm.disabled = false;
380
408
  confirm.addEventListener("click", () => {
381
409
  closePopover();
382
- void session.command(`/rewind @${seq} both`);
410
+ opts.onRewind("both");
383
411
  });
384
412
  })().catch(() => {
385
413
  impact.textContent = t("popover.impact.failed", { message: "unexpected error" });
@@ -399,7 +427,7 @@ function openPopover(opts) {
399
427
  el("div", CLASS.popoverTarget, formatTarget(t, seq, time, preview)),
400
428
  modeOption(t("popover.chat"), t("popover.chat.hint"), () => {
401
429
  closePopover();
402
- void session.command(`/rewind @${seq} chat`);
430
+ opts.onRewind("chat");
403
431
  })
404
432
  ];
405
433
  if (bothState.state === "noChanges") {
@@ -436,7 +464,6 @@ function openPopover(opts) {
436
464
  renderModes();
437
465
  document.body.append(root);
438
466
  position();
439
- const hasFileImpact = (text) => text === void 0 || text.includes("\u5C06\u5F71\u54CD");
440
467
  void (async () => {
441
468
  const outcome = await previewImpact(session, seq);
442
469
  impactOutcome = outcome;
@@ -474,7 +501,7 @@ function openPopover(opts) {
474
501
  var name = "dsh-rewind";
475
502
  var inject = ["sessions", "locale"];
476
503
  var NS = "rewind";
477
- var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"]';
504
+ var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"], [data-chat-flow-kind="steering"]';
478
505
  var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
479
506
  var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
480
507
  var COMPOSER_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
@@ -511,7 +538,6 @@ function apply(ctx) {
511
538
  style.dataset.plugin = "dsh-rewind";
512
539
  style.textContent = STYLE;
513
540
  document.head.appendChild(style);
514
- const attached = /* @__PURE__ */ new WeakSet();
515
541
  const hidden = /* @__PURE__ */ new WeakSet();
516
542
  const buttons = /* @__PURE__ */ new Map();
517
543
  let observer = null;
@@ -529,16 +555,17 @@ function apply(ctx) {
529
555
  };
530
556
  const userNodeFor = (key, session) => {
531
557
  const node = session.getSnapshot().chat.nodes.get(key);
532
- if (node === void 0 || node.kind !== "user") return void 0;
558
+ if (node === void 0 || node.kind !== "user" && node.kind !== "steering") return void 0;
533
559
  return node.data;
534
560
  };
535
561
  const attach = (seat) => {
536
562
  const key = seat.dataset.chatAnchorKey;
537
- if (key === void 0 || attached.has(seat)) return;
563
+ if (key === void 0) return;
564
+ const existing = buttons.get(key);
565
+ if (existing !== void 0 && existing.isConnected) return;
538
566
  const hoverRoot = seat.querySelector(ACTIONS_ROOT_SELECTOR);
539
567
  const actions = hoverRoot?.lastElementChild;
540
568
  if (!(actions instanceof HTMLElement) || actions.querySelector("button") === null) return;
541
- attached.add(seat);
542
569
  const button = document.createElement("button");
543
570
  button.type = "button";
544
571
  button.className = CLASS.button;
@@ -560,7 +587,10 @@ function apply(ctx) {
560
587
  time: node.time,
561
588
  preview: messagePreviewOf(node),
562
589
  anchor: button,
563
- t
590
+ t,
591
+ onRewind: (mode) => {
592
+ void runRewindAndFill(session, node.seq, mode);
593
+ }
564
594
  });
565
595
  });
566
596
  actions.appendChild(button);
@@ -586,20 +616,6 @@ function apply(ctx) {
586
616
  hidden.delete(seat);
587
617
  }
588
618
  }
589
- if (session !== void 0) {
590
- const snap = session.getSnapshot();
591
- if (!fillBaselineTaken && snap.chat.order.length > 0) {
592
- fillBaselineTaken = true;
593
- for (const key of snap.chat.order) {
594
- const node = snap.chat.nodes.get(key);
595
- if (node?.kind === "command") {
596
- const command = node.data;
597
- if (command.seq > fillBaselineSeq) fillBaselineSeq = command.seq;
598
- }
599
- }
600
- }
601
- fillComposerForRewind(session, filledTargets);
602
- }
603
619
  if (hiddenSeqs.size > 0 || hiddenCount > 0) {
604
620
  console.info(
605
621
  `[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
@@ -609,24 +625,16 @@ function apply(ctx) {
609
625
  if (!hidden.has(seat)) attach(seat);
610
626
  }
611
627
  };
612
- const filledTargets = /* @__PURE__ */ new Set();
613
- let fillBaselineSeq = -1;
614
- let fillBaselineTaken = false;
615
- const fillComposerForRewind = (session, filled) => {
616
- const snap = session.getSnapshot();
617
- for (const key of snap.chat.order) {
618
- const node = snap.chat.nodes.get(key);
619
- if (node === void 0 || node.kind !== "command") continue;
620
- const command = node.data;
621
- if (command.name !== "rewind" || command.outcome?.kind !== "success") continue;
622
- if (command.seq <= fillBaselineSeq) continue;
623
- if (command.outcome.sourceEventSeq === void 0) continue;
624
- const target = targetOfOutcome(command.outcome.text);
625
- if (target === void 0 || filled.has(target)) continue;
626
- const text = userTextAt(session, target);
627
- if (text === void 0 || text === "") continue;
628
- if (fillComposer(text)) filled.add(target);
629
- }
628
+ const runRewindAndFill = async (session, seq, mode) => {
629
+ const known = knownCommandSeqs(session, (node) => isExecutedRewindCommand(node, seq));
630
+ const result = await session.command(`/rewind @${seq} ${mode}`);
631
+ if (!result.ok || result.value?.matched !== true) return;
632
+ const outcome = await waitForCommand(session, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
633
+ if (outcome === null || outcome.kind !== "success") return;
634
+ if (sessionFor() !== session) return;
635
+ const text = userTextAt(session, seq);
636
+ if (text === void 0 || text === "") return;
637
+ fillComposer(text);
630
638
  };
631
639
  const MANUAL_REWIND = /^\s*\/rewind(?:\s|$)/i;
632
640
  const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR);
package/lib/index.js CHANGED
@@ -309,7 +309,7 @@ function mutationPathOf(exec) {
309
309
  }
310
310
  function anchorSeqOf(session, cache) {
311
311
  const events = session.events;
312
- const cached = cache.get(session.id);
312
+ const cached = cache.get(session);
313
313
  if (cached !== void 0 && cached.eventsLength === events.length) return cached.anchor;
314
314
  let anchor = cached?.anchor;
315
315
  for (let i = events.length - 1; i >= (cached?.eventsLength ?? 0); i--) {
@@ -318,7 +318,7 @@ function anchorSeqOf(session, cache) {
318
318
  break;
319
319
  }
320
320
  }
321
- cache.set(session.id, { anchor, eventsLength: events.length });
321
+ cache.set(session, { anchor, eventsLength: events.length });
322
322
  return anchor;
323
323
  }
324
324
  async function resolveTarget(fs, path, cwd, signal) {
@@ -342,6 +342,8 @@ async function readTextOrUndefined(fs, target, signal) {
342
342
  }
343
343
  async function captureBefore(fs, exec, pending) {
344
344
  if (!TRACKED_TOOLS.has(exec.name)) return;
345
+ const header = exec.agent?.session.header;
346
+ if (header !== void 0 && (header.origin === "subagent" || (header.delegationDepth ?? 0) > 0)) return;
345
347
  const path = mutationPathOf(exec);
346
348
  if (path === void 0) return;
347
349
  const cwd = execSessionCwd(exec, path);
@@ -388,6 +390,7 @@ function formatPlan(plan, files) {
388
390
  } else {
389
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");
390
392
  }
393
+ lines.push(`impact=${files.length}`);
391
394
  return lines.join("\n");
392
395
  }
393
396
  function resolveOrError(events, surface, raw) {
@@ -402,56 +405,81 @@ function renderFailures(failed) {
402
405
  return `\uFF1B${failed.length} \u4E2A\u6587\u4EF6\u8FD8\u539F\u5931\u8D25\uFF1A${failed.map((f) => `${f.path}\uFF08${f.message}\uFF09`).join("\u3001")}`;
403
406
  }
404
407
  async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
405
- const deadline = Date.now() + timeoutMs;
406
- while (agent.status !== "idle") {
407
- if (signal.aborted || Date.now() > deadline) return false;
408
- await new Promise((resolve) => setTimeout(resolve, 50));
408
+ if (signal.aborted) return false;
409
+ let timer;
410
+ let onAbort;
411
+ try {
412
+ await Promise.race([
413
+ agent.whenIdle(),
414
+ new Promise((_resolve, reject) => {
415
+ timer = setTimeout(() => reject(new Error("rewind idle wait timed out")), timeoutMs);
416
+ onAbort = () => reject(new Error("rewind idle wait aborted"));
417
+ signal.addEventListener("abort", onAbort, { once: true });
418
+ })
419
+ ]);
420
+ return true;
421
+ } catch {
422
+ return false;
423
+ } finally {
424
+ if (timer !== void 0) clearTimeout(timer);
425
+ if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
409
426
  }
410
- return true;
411
427
  }
412
- async function executeRewind(ctx, store, invocation, rawTarget, mode) {
428
+ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight) {
413
429
  const { agent } = invocation;
414
- if (agent.status !== "idle") {
415
- agent.cancel({ kind: "user" });
416
- const stopped = await waitForAgentIdle(agent, invocation.signal);
417
- if (!stopped) {
418
- 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" };
419
- }
420
- }
421
- let plan;
422
- try {
423
- plan = resolveOrError(agent.session.events, agent.session.surface.nodes, rawTarget);
424
- } catch (error) {
425
- return rewindErrorResult(error);
430
+ const sessionId = agent.session.id;
431
+ 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" };
426
433
  }
427
- const marker = buildMarker();
428
- let event;
434
+ inflight.add(sessionId);
429
435
  try {
430
- event = agent.session.append("assistant/message", { turn: markerTurnOf(agent.session.events), step: 0, message: marker }, {
431
- surfaceOp: { op: "replace", start: plan.surfaceStart, end: plan.surfaceEnd },
432
- sourceEventSeqs: [...plan.shadowedSeqs]
433
- });
434
- } catch (error) {
436
+ if (agent.status !== "idle") {
437
+ agent.cancel({ kind: "user" });
438
+ const stopped = await waitForAgentIdle(agent, invocation.signal);
439
+ 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" };
441
+ }
442
+ }
443
+ if (invocation.signal.aborted) {
444
+ return { kind: "error", text: "\u56DE\u9000\u5DF2\u53D6\u6D88\u3002" };
445
+ }
446
+ let plan;
447
+ try {
448
+ plan = resolveOrError(agent.session.events, agent.session.surface.nodes, rawTarget);
449
+ } catch (error) {
450
+ return rewindErrorResult(error);
451
+ }
452
+ const marker = buildMarker();
453
+ let event;
454
+ try {
455
+ event = agent.session.append("assistant/message", { turn: markerTurnOf(agent.session.events), step: 0, message: marker }, {
456
+ surfaceOp: { op: "replace", start: plan.surfaceStart, end: plan.surfaceEnd },
457
+ sourceEventSeqs: [...plan.shadowedSeqs]
458
+ });
459
+ } catch (error) {
460
+ return {
461
+ kind: "error",
462
+ text: `\u56DE\u9000\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}\u3002\u4F1A\u8BDD\u672A\u6539\u53D8\u3002`
463
+ };
464
+ }
465
+ let restore = "";
466
+ if (mode === "both") {
467
+ const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
468
+ 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";
473
+ restore += renderFailures(outcome.failed);
474
+ }
435
475
  return {
436
- kind: "error",
437
- text: `\u56DE\u9000\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}\u3002\u4F1A\u8BDD\u672A\u6539\u53D8\u3002`
476
+ 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`,
478
+ sourceEventSeq: event.seq
438
479
  };
480
+ } finally {
481
+ inflight.delete(sessionId);
439
482
  }
440
- let restore = "";
441
- if (mode === "both") {
442
- const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
443
- const parts = [];
444
- if (outcome.restored.length > 0) parts.push(`\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6`);
445
- if (outcome.deleted.length > 0) parts.push(`\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6`);
446
- if (outcome.skipped.length > 0) parts.push(`\u8DF3\u8FC7 ${outcome.skipped.length} \u4E2A\u94FE\u63A5`);
447
- restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4";
448
- restore += renderFailures(outcome.failed);
449
- }
450
- return {
451
- kind: "success",
452
- text: `\u5DF2\u64A4\u56DE seq ${plan.targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09${restore}\u3002`,
453
- sourceEventSeq: event.seq
454
- };
455
483
  }
456
484
  function rewindErrorResult(error) {
457
485
  if (error instanceof RewindError) {
@@ -465,7 +493,7 @@ function rewindErrorResult(error) {
465
493
  }
466
494
  throw error;
467
495
  }
468
- async function handleRewind(ctx, store, invocation) {
496
+ async function handleRewind(ctx, store, invocation, inflight) {
469
497
  const session = invocation.agent.session;
470
498
  const input = invocation.rawInput.trim();
471
499
  if (input === "") {
@@ -473,7 +501,7 @@ async function handleRewind(ctx, store, invocation) {
473
501
  if (candidates.length === 0) {
474
502
  return { kind: "error", text: "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002" };
475
503
  }
476
- return executeRewind(ctx, store, invocation, `@${candidates[0].seq}`, "chat");
504
+ return executeRewind(ctx, store, invocation, `@${candidates[0].seq}`, "chat", inflight);
477
505
  }
478
506
  const parts = input.split(/\s+/);
479
507
  if (parts[0] === "preview") {
@@ -503,17 +531,18 @@ async function handleRewind(ctx, store, invocation) {
503
531
  /rewind ${target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6`
504
532
  };
505
533
  }
506
- return executeRewind(ctx, store, invocation, target, mode);
534
+ return executeRewind(ctx, store, invocation, target, mode, inflight);
507
535
  }
508
536
  function apply(ctx, config) {
509
537
  const store = new SnapshotStore(config?.snapshotDir);
510
538
  const pending = /* @__PURE__ */ new Map();
511
- const anchorCache = /* @__PURE__ */ new Map();
539
+ const anchorCache = /* @__PURE__ */ new WeakMap();
540
+ const inflight = /* @__PURE__ */ new Set();
512
541
  ctx.effect(function* () {
513
542
  yield ctx.commands.register({
514
543
  name: "rewind",
515
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",
516
- handler: (invocation) => handleRewind(ctx, store, invocation)
545
+ handler: (invocation) => handleRewind(ctx, store, invocation, inflight)
517
546
  });
518
547
  }, "dsh-rewind command");
519
548
  ctx.inject(["fs"], (scope) => {
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @module dsh-rewind/client/hidden
7
7
  */
8
- import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-runtime/client';
8
+ import type { ChatConversationViewNode, CommandNode } from '@deepseek-ai/dsh-client-runtime/client';
9
9
  /** Minimal chat snapshot reader the hiding logic needs. */
10
10
  export interface HiddenChat {
11
11
  readonly order: readonly string[];
@@ -15,6 +15,25 @@ export interface HiddenChat {
15
15
  }
16
16
  /** Extract the rewind target from a command outcome text ("已撤回 seq N..."). */
17
17
  export declare function targetOfOutcome(text: string | undefined): number | undefined;
18
+ /**
19
+ * True when a `/rewind` command node is an EXECUTED rewind for `seq` — the
20
+ * admission form the popover drives (`@<seq> chat` / `both`) that settled
21
+ * with a marker-carrying success outcome. The composer refill waits for
22
+ * exactly this node after the user confirms, so a history-loaded command can
23
+ * never trigger a fill.
24
+ */
25
+ export declare function isExecutedRewindCommand(node: CommandNode, seq: number): boolean;
26
+ /**
27
+ * Whether a preview outcome reports tracked file changes — the availability
28
+ * of the "rewind conversation and code" option (Claude Code hides the
29
+ * code-restore options when the checkpoint has no tracked changes).
30
+ *
31
+ * Prefers the machine-readable `impact=<n>` trailer the current host appends
32
+ * to preview text. Older host output (or a history-loaded preview row from
33
+ * before the trailer existed) has none, so it falls back to the human copy
34
+ * ("将影响 …") to keep mixed-version deployments correct.
35
+ */
36
+ export declare function hasFileImpact(text: string | undefined): boolean;
18
37
  /**
19
38
  * Anchor seqs that must be hidden from the rendered transcript so the user
20
39
  * sees the conversation as the agent sees it: every impact-preview flow node
@@ -7,6 +7,7 @@
7
7
  * @module dsh-rewind/client/popover
8
8
  */
9
9
  import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
10
+ import type { CommandNode } from '@deepseek-ai/dsh-client-runtime/client';
10
11
  import type { RewindKey } from './locales.ts';
11
12
  type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
12
13
  export interface PopoverOptions {
@@ -17,9 +18,32 @@ export interface PopoverOptions {
17
18
  /** The button that opened the popover (outside-click ignore target). */
18
19
  readonly anchor: HTMLElement;
19
20
  readonly t: Translate;
21
+ /**
22
+ * Execute one rewind in the given mode. The popover closes itself first;
23
+ * the callback owns the command + composer-refill lifecycle (see
24
+ * runRewindAndFill in index.ts).
25
+ */
26
+ readonly onRewind: (mode: 'chat' | 'both') => void;
20
27
  }
21
28
  /** Close the current popover, if any. */
22
29
  export declare function closePopover(): void;
30
+ /**
31
+ * Seqs of the command nodes currently matching `match`. Sample BEFORE issuing
32
+ * a new command of the same shape so the subsequent wait can exclude them: a
33
+ * repeated preview/rewind of the same target must not settle on the previous
34
+ * command's stale outcome (e.g. an older preview that found file changes,
35
+ * after those changes were already restored).
36
+ */
37
+ export declare function knownCommandSeqs(session: SessionFace, match: (node: CommandNode) => boolean): Set<number>;
38
+ /**
39
+ * Resolve the outcome of the newest matching rewind command by watching the
40
+ * session snapshot (command/run + command/done land as one CommandNode).
41
+ * @returns the outcome text-bearing node, or null on timeout.
42
+ */
43
+ export declare function waitForCommand(session: SessionFace, match: (node: CommandNode) => boolean, timeoutMs?: number): Promise<{
44
+ kind: 'success' | 'error';
45
+ text?: string;
46
+ } | null>;
23
47
  /** Open the mode-selection popover anchored near the given button. */
24
48
  export declare function openPopover(opts: PopoverOptions): void;
25
49
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -23,6 +23,9 @@
23
23
  "type": "module",
24
24
  "main": "lib/index.js",
25
25
  "types": "lib/types/index.d.ts",
26
+ "bin": {
27
+ "dsh-rewind-repair": "scripts/repair-markers.mjs"
28
+ },
26
29
  "exports": {
27
30
  ".": {
28
31
  "types": "./lib/types/index.d.ts",
@@ -41,7 +44,8 @@
41
44
  "README.zh.md",
42
45
  "docs",
43
46
  "assets",
44
- "LICENSE"
47
+ "LICENSE",
48
+ "scripts/repair-markers.mjs"
45
49
  ],
46
50
  "dsh": {
47
51
  "bundle": {
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Offline repair for sessions corrupted by the ≤ 0.2.4 marker turn-number bug.
4
+ *
5
+ * Symptom: reopening a session shows
6
+ * Failed to load history: conversation Context …:turn-tail… received an
7
+ * update before its start Match (internal)
8
+ * and the history disappears. Root cause: the rewind marker was numbered
9
+ * `lastTurn + 1`, which is exactly how the harness numbers its NEXT real turn —
10
+ * so the log holds an `assistant/message` (the marker) BEFORE the `turn/start`
11
+ * of the same turn, and the client conversation-context builder rejects that
12
+ * ordering.
13
+ *
14
+ * This script rewrites ONLY the `data.turn` of `dsh-rewind` empty-marker events
15
+ * to the last turn/start that precedes them (a turn the harness has already
16
+ * consumed and can never reuse). It preserves:
17
+ * - every other event byte-for-byte (seqs, times, surface metadata, …),
18
+ * - the JSONL record order and line structure,
19
+ * - the zstd multi-frame structure the persistence backend expects
20
+ * (each frame is decoded, fixed, and re-compressed independently, with the
21
+ * "last started turn" state threaded across frames).
22
+ *
23
+ * The original file is backed up to `session.jsonl.zstd.bak-<timestamp>` before
24
+ * the first write. Run with the host fully stopped (a resident session would
25
+ * overwrite the repaired file at its next checkpoint), then restart dsh web.
26
+ *
27
+ * Usage:
28
+ * node scripts/repair-markers.mjs # scan ~/.dsh/sessions
29
+ * node scripts/repair-markers.mjs --dry-run # report only, no writes
30
+ * node scripts/repair-markers.mjs --dir <sessions root>
31
+ */
32
+
33
+ import { readdirSync, readFileSync, writeFileSync, copyFileSync, statSync, existsSync } from 'node:fs'
34
+ import { join } from 'node:path'
35
+ import { homedir } from 'node:os'
36
+ import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
37
+
38
+ const ZSTD_MAGIC = 0xfd2fb528
39
+ const MARKER_SOURCE = { provider: 'dsh-rewind', model: 'rewind-marker' }
40
+ const DEFAULT_ROOT = join(homedir(), '.dsh', 'sessions')
41
+
42
+ /** @param {Buffer} buffer @returns {{start:number,end:number}[]} */
43
+ function scanFrames(buffer) {
44
+ const frames = []
45
+ let offset = 0
46
+ while (offset < buffer.length) {
47
+ const start = offset
48
+ if (buffer.length - offset < 4 || buffer.readUInt32LE(offset) !== ZSTD_MAGIC) break
49
+ offset += 4
50
+ const next = buffer.indexOf(Buffer.from([0x28, 0xb5, 0x2f, 0xfd]), offset)
51
+ frames.push(next === -1 ? { start, end: buffer.length } : { start, end: next })
52
+ offset = next === -1 ? buffer.length : next
53
+ }
54
+ return frames
55
+ }
56
+
57
+ /**
58
+ * Rewrite marker turn numbers in ONE frame's JSONL text. Line-preserving: the
59
+ * fix never adds or removes lines, only rewrites a marker line's turn. State
60
+ * (`lastStarted`) is threaded across frames, because a marker in a later frame
61
+ * must know the turn/start events of earlier frames.
62
+ * @param {string} text - this frame's JSONL text.
63
+ * @param {{lastStarted:number}} state - running "last started turn" state.
64
+ * @returns {{text:string, changed:number}} the fixed text (identical when
65
+ * nothing changed) and the number of rewritten markers in this frame.
66
+ */
67
+ function fixMarkerTurns(text, state) {
68
+ const lines = text.split('\n')
69
+ let changed = 0
70
+ const out = []
71
+ for (const line of lines) {
72
+ if (line.trim() === '') {
73
+ out.push(line)
74
+ continue
75
+ }
76
+ let record
77
+ try {
78
+ record = JSON.parse(line)
79
+ } catch {
80
+ out.push(line) // keep unknown/torn lines verbatim
81
+ continue
82
+ }
83
+ if (record && record.type === 'turn/start' && Number.isSafeInteger(record.data?.turn)) {
84
+ if (record.data.turn > state.lastStarted) state.lastStarted = record.data.turn
85
+ out.push(line)
86
+ continue
87
+ }
88
+ const isMarker =
89
+ record?.type === 'assistant/message' &&
90
+ record.data?.message?.source?.provider === MARKER_SOURCE.provider &&
91
+ record.data?.message?.source?.model === MARKER_SOURCE.model
92
+ if (!isMarker) {
93
+ out.push(line)
94
+ continue
95
+ }
96
+ const badTurn = record.data.turn
97
+ if (badTurn === state.lastStarted) {
98
+ out.push(line) // already safe (post-0.2.5 marker) — idempotent
99
+ continue
100
+ }
101
+ changed += 1
102
+ out.push(JSON.stringify({ ...record, data: { ...record.data, turn: state.lastStarted } }))
103
+ }
104
+ return { text: out.join('\n'), changed }
105
+ }
106
+
107
+ /** Repair one session artifact file. @returns {string} a human summary line. */
108
+ function repairFile(file, dryRun) {
109
+ const original = readFileSync(file)
110
+ const frames = scanFrames(original)
111
+ const state = { lastStarted: 0 }
112
+ const fixedTexts = []
113
+ let changed = 0
114
+ for (const frame of frames) {
115
+ const frameText = zstdDecompressSync(original.subarray(frame.start, frame.end)).toString('utf8')
116
+ const result = fixMarkerTurns(frameText, state)
117
+ changed += result.changed
118
+ fixedTexts.push(result.text)
119
+ }
120
+ if (changed === 0) return ` ok (no marker fix needed): ${file}`
121
+ if (dryRun) return ` would fix ${changed} marker(s): ${file}`
122
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
123
+ copyFileSync(file, `${file}.bak-${stamp}`)
124
+ // Preserve the zstd frame structure: one re-compressed frame per original
125
+ // frame, in order.
126
+ writeFileSync(file, Buffer.concat(fixedTexts.map(text => zstdCompressSync(Buffer.from(text)))))
127
+ return ` fixed ${changed} marker(s): ${file} (backup: ${file}.bak-${stamp})`
128
+ }
129
+
130
+ function collectSessions(root) {
131
+ const files = []
132
+ for (const project of readdirSync(root)) {
133
+ const projectDir = join(root, project)
134
+ let stat
135
+ try {
136
+ stat = statSync(projectDir)
137
+ } catch {
138
+ continue
139
+ }
140
+ if (!stat.isDirectory()) continue
141
+ for (const entry of readdirSync(projectDir)) {
142
+ const file = join(projectDir, entry, 'session.jsonl.zstd')
143
+ if (existsSync(file)) files.push(file)
144
+ }
145
+ }
146
+ return files
147
+ }
148
+
149
+ const args = process.argv.slice(2)
150
+ const dryRun = args.includes('--dry-run')
151
+ const dirIndex = args.indexOf('--dir')
152
+ const root = dirIndex !== -1 && args[dirIndex + 1] !== undefined ? args[dirIndex + 1] : DEFAULT_ROOT
153
+
154
+ if (!existsSync(root)) {
155
+ console.error(`sessions root not found: ${root}`)
156
+ process.exit(1)
157
+ }
158
+
159
+ console.log(`${dryRun ? '[dry-run] ' : ''}scanning ${root}`)
160
+ const files = collectSessions(root)
161
+ console.log(`${files.length} session artifact(s) found`)
162
+ let fixedCount = 0
163
+ for (const file of files) {
164
+ try {
165
+ const line = repairFile(file, dryRun)
166
+ if (!line.includes('no marker fix needed')) {
167
+ console.log(line)
168
+ fixedCount += 1
169
+ }
170
+ } catch (error) {
171
+ console.error(` ERROR ${file}: ${error instanceof Error ? error.message : String(error)}`)
172
+ }
173
+ }
174
+ console.log(fixedCount === 0
175
+ ? 'no corrupted sessions found — nothing to do'
176
+ : `${dryRun ? 'would repair' : 'repaired'} ${fixedCount} session(s). ${dryRun ? '' : 'Restart dsh web to reload the repaired histories.'}`)