dsh-rewind-plugin 0.2.6 → 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.6) 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)
@@ -163,6 +165,7 @@ Rewinding to a message **withdraws** it and everything after it — the transcri
163
165
  ## Behavior details & limitations
164
166
 
165
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.
166
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**.
167
170
  - File restore/delete writes through the **real local filesystem**; under sandbox / remote backends path resolution may be restricted.
168
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.6),经 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)
@@ -154,6 +156,7 @@ dsh plugin --profile web add github:SiriLee/dsh-rewind#<commit-sha>
154
156
  ## 行为细节与限制
155
157
 
156
158
  - 只跟踪**插件运行期间、经写类工具**的变更(`write` / `edit` / `str_replace_editor`)。`bash`、其他工具或外部程序的修改不在备份内、无法还原——与 Claude Code 相同,官方同样不覆盖,此类回退交由用户 git 处理。
159
+ - **子代理(subagent)的编辑不跟踪**——与 Claude Code 相同。子代理运行在自己的会话里,其备份无法被父会话的回退还原;插件直接跳过捕获,而不是记录到永远读不到的位置。
157
160
  - 写前备份读取失败时(如权限错误)该次变更不会入备份,`both` 回退无法还原它——插件会在日志中警告,但**不会阻塞写操作**。
158
161
  - 文件还原/删除直写**真实本地文件系统**;sandbox / 远程 backend 下路径解析可能受限。
159
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.6",
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",