dsh-rewind-plugin 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -11,7 +11,7 @@ Conversation rewind for DeepSeek Harness: **rewind the conversation to any earli
11
11
  A deliberately focused plugin with one job: **rewind to any user message, no matter how far back, in place** — and conveniently **restore the files it changed** along the way.
12
12
 
13
13
  - **Rewinding is time-travel** — the target message and everything after it (agent replies, tool calls) are withdrawn from the model context *and* the rendered transcript at once, with no new session and no window switch; the target's text is offered back in the composer so you can edit and re-send it — **truly seamless and convenient by design**.
14
- - **Lightweight workspace backup** — Claude Code-aligned behavior: only file-writing tools are tracked, a lightweight before-backup is **persisted on disk**, and your git repository is never touched or relied on. One lightweight plugin gives you a **complete** agentic rewind capability.
14
+ - **Lightweight workspace backup** — Claude Code-aligned behavior: tracks files edited by the write-class tools, and external changes to **already-tracked** files are restorable too. Selective tracking, before-write backup, store only on change. One lightweight plugin gives you a **complete** agentic rewind capability.
15
15
  - **Privacy-first** — the plugin never deletes or rewrites the session log (append-only) and never actually deletes any of your conversation; file restores stay inside the plugin's own backup directory. Full security model: [SECURITY.md](SECURITY.md).
16
16
  - **A complete test system** — unit, probe, and end-to-end host verification, covering compatibility probing, log replay, resume, cross-restart and other scenarios; maintained continuously as the harness evolves to ensure feature stability.
17
17
 
package/README.md CHANGED
@@ -11,7 +11,7 @@ DeepSeek Harness 插件:**一键就地回退对话到任意更早的用户消
11
11
  刻意聚焦、保持极简,只做一件事:**就地回退到任意远的用户消息**,还能**顺手还原改过的文件**。
12
12
 
13
13
  - **回退 = 时间回溯**——目标消息及其之后的全部内容(agent 回复、工具调用)同时从**模型上下文**和**渲染对话**中撤回,不新建会话、不切换窗口;目标消息文本会回填输入框,改完可重发。**在原理上就真正无感、便捷**。
14
- - **轻量工作区备份**——行为对齐 Claude Code:只跟踪写文件的工具,写前做轻量备份并**落盘持久化**,不依赖、也不触碰 git 仓库。一个轻型插件,即拥有**完备的智能体回退能力**。
14
+ - **轻量工作区备份**——对齐 Claude Code:追踪写类工具编辑过的文件,**已跟踪文件的外部变更也能还原**。局部追踪、写前备份、不变不存。一个轻型插件,即拥有**完备的智能体回退能力**。
15
15
  - **信息安全优先**——插件从不删改会话日志(append-only),从不真正删除你的任何对话;文件还原限定在插件自己的备份目录。完整安全模型:[SECURITY.md](SECURITY.md)。
16
16
  - **完备测试系统**——单元、探针、端到端主机验证,覆盖兼容性探测、日志重放、续接、跨重启等场景;随 harness 升级持续维护,确保功能稳定。
17
17
 
@@ -17,6 +17,22 @@
17
17
  > `0.1.2-rc.x`, `0.2.x`) requires appending a union member; rc rolling within a
18
18
  > tuple (`0.1.1-rc.2 → rc.3`) is a no-op. Signal: `npm view @deepseek-ai/dsh version`;
19
19
  > flow: `scripts/check-dsh-version.mjs`.
20
+ >
21
+ > Client channel (harness `0.1.2-alpha.1`, a **bundled pre-release — NOT published
22
+ > to npm**): the plugin also reads the session chat when a DSH Desktop build ships
23
+ > that version (`latest` on the registry is still `0.1.1-rc.2`; the alpha is only
24
+ > in the bundled `app.asar.unpacked/node_modules/@deepseek-ai/*`). There
25
+ > `uiConversation` exposes the chat as a per-session named `"chat"` view
26
+ > (contributed by `dsh-client-ui-chat` through the `uiSession` slot hook) instead
27
+ > of the session-face `chat` field. `src/client/hidden.ts` (`chatSnapshotOf`) reads
28
+ > the session-face snapshot first (rc.2 path), then the `uiConversation` `"chat"`
29
+ > view (alpha.1 path); both missing degrades to `undefined` (no targets, never a
30
+ > crash). Peers are intentionally **NOT** extended for `0.1.2-alpha.1` because it is
31
+ > not a registry version (see above); the OR-union stays
32
+ > `^0.1.0-rc.6 || ^0.1.1-rc.2` and will gain the `0.1.2` tuple when the harness
33
+ > publishes it. Pinned by `tests/chat-channel.test.ts` (channel precedence + the
34
+ > alpha.1 snapshot shape) and `tests/client-dom.test.ts` (the button-target
35
+ > pairing that consumes the chat).
20
36
 
21
37
  ## Definition of "fully compatible" (invariants)
22
38
 
@@ -38,6 +38,9 @@ npm version patch
38
38
  git push origin main --tags # triggers .github/workflows/publish.yml
39
39
  ```
40
40
 
41
+ - **Before bumping, manually confirm there is no newer DSH version the plugin
42
+ has not been verified against** (a pre-release can ship in DSH Desktop
43
+ without being on npm, e.g. `0.1.2-alpha.1`; see docs/compat/audit.md).
41
44
  - The workflow verifies the tag matches `package.json`, runs typecheck + tests +
42
45
  a full build + artifact verification, publishes with `--provenance`
43
46
  (Sigstore), and creates a GitHub Release. It is **idempotent** — an already
@@ -58,9 +61,11 @@ uses an OR-union covering every published rc tuple series
58
61
  (`0.1.1 → 0.1.2 → 0.2.x`); rc rolling within a tuple (`0.1.1-rc.2 → rc.3`)
59
62
  needs nothing. All `@deepseek-ai/*` packages release together;
60
63
  `npm view @deepseek-ai/dsh version` is the authoritative signal.
61
- - **Automatic detection**: `node scripts/check-dsh-version.mjs` compares the
62
- latest npm version against the tuples the peers cover and reports whether an
63
- extension is needed (exit 0 = nothing to do, exit 1 = update).
64
+ - **Published-tuple check (optional)**: `node scripts/check-dsh-version.mjs`
65
+ compares the latest npm version against the tuples the peers cover (exit 0 =
66
+ nothing to do, exit 1 = update). It reads npm published versions only; a
67
+ newer-but-unpublished pre-release is a manual pre-release check — see the
68
+ "Before bumping" step above.
64
69
  - **Update steps**: append `|| ^<new-tuple>-rc.<n>` to every
65
70
  `@deepseek-ai/dsh-*` peer → bump devDependencies to the latest → `npm
66
71
  install` → `npm run check` → release.
@@ -37,6 +37,7 @@ npm version patch
37
37
  git push origin main --tags # 触发 .github/workflows/publish.yml
38
38
  ```
39
39
 
40
+ - **升版前手动确认**:确认无插件未针对其验证过的更新 DSH 版本(pre-release 可能只随 Desktop 捆绑、而不发到 npm,如 `0.1.2-alpha.1`;见 docs/compat/audit.md)。
40
41
  - workflow 校验 tag 与 `package.json` 版本一致,跑 typecheck + 测试 + 完整
41
42
  构建 + 产物验证,以 `--provenance`(Sigstore)发布并创建 GitHub Release。
42
43
  **幂等**——已发布的版本会跳过。
@@ -54,8 +55,9 @@ OR 并集覆盖 DSH 已发布的每个 rc 元组系列(如 `^0.1.0-rc.6 || ^0.
54
55
  - **何时需要更新**:仅当 DSH 发布新元组(`0.1.1 → 0.1.2 → 0.2.x`)时;
55
56
  同元组内 rc 滚动(`0.1.1-rc.2 → rc.3`)无需动作。DSH 所有包同版本发布,
56
57
  `npm view @deepseek-ai/dsh version` 即权威信号。
57
- - **自动检测**:`node scripts/check-dsh-version.mjs` 对比 npm 最新版本与
58
- peer 覆盖的元组,输出是否需追加(exit 0 无需动作,exit 1 需要)。
58
+ - **已发布元组检查(可选)**:`node scripts/check-dsh-version.mjs` npm 最新版本
59
+ 对比 peer 覆盖的元组(exit 0 无需动作,exit 1 需要)。它**只读 npm 已发布版本**;
60
+ 更新的未发布 pre-release 走**手动发布前检查**——见上文"升版前手动确认"。
59
61
  - **更新步骤**:给每个 `@deepseek-ai/dsh-*` peer 追加 `|| ^<新元组>-rc.<n>`
60
62
  → devDependencies 同步升到最新 → `npm install` → `npm run check` → 发版。
61
63
  - **正式版后收敛**:DSH 发布 final 版本后,正式版不受 prerelease 元组规则
package/lib/client.js CHANGED
@@ -35,6 +35,11 @@ __export(index_exports, {
35
35
  module.exports = __toCommonJS(index_exports);
36
36
 
37
37
  // src/client/hidden.ts
38
+ function chatSnapshotOf(face, chatView) {
39
+ const legacy = face?.getSnapshot().chat;
40
+ if (legacy !== void 0) return legacy;
41
+ return chatView?.getSnapshot() ?? void 0;
42
+ }
38
43
  function targetSeqOfArgs(args) {
39
44
  if (args === void 0 || args === null) return void 0;
40
45
  const match = args.match(/@(\d+)/);
@@ -333,10 +338,11 @@ function parseImpactList(text) {
333
338
  }
334
339
  return { restores, deletes };
335
340
  }
336
- function findCommand(snapshot, match) {
341
+ function findCommand(chat, match) {
342
+ if (chat === void 0) return void 0;
337
343
  let found;
338
- for (const key of snapshot.chat.order) {
339
- const node = snapshot.chat.nodes.get(key);
344
+ for (const key of chat.order) {
345
+ const node = chat.nodes.get(key);
340
346
  if (node !== void 0 && node.kind === "command") {
341
347
  const command = node.data;
342
348
  if (match(command)) found = command;
@@ -344,11 +350,12 @@ function findCommand(snapshot, match) {
344
350
  }
345
351
  return found;
346
352
  }
347
- function knownCommandSeqs(session, match) {
353
+ function knownCommandSeqs(session, chatOf, match) {
348
354
  const known = /* @__PURE__ */ new Set();
349
- const snapshot = session.getSnapshot();
350
- for (const key of snapshot.chat.order) {
351
- const node = snapshot.chat.nodes.get(key);
355
+ const chat = chatOf(session);
356
+ if (chat === void 0) return known;
357
+ for (const key of chat.order) {
358
+ const node = chat.nodes.get(key);
352
359
  if (node !== void 0 && node.kind === "command") {
353
360
  const command = node.data;
354
361
  if (match(command)) known.add(command.seq);
@@ -356,7 +363,7 @@ function knownCommandSeqs(session, match) {
356
363
  }
357
364
  return known;
358
365
  }
359
- function waitForCommand(session, match, timeoutMs = 8e3) {
366
+ function waitForCommand(session, chatOf, match, timeoutMs = 8e3) {
360
367
  return new Promise((resolve) => {
361
368
  let settled = false;
362
369
  const settle = (value) => {
@@ -367,7 +374,7 @@ function waitForCommand(session, match, timeoutMs = 8e3) {
367
374
  resolve(value);
368
375
  };
369
376
  const check = () => {
370
- const node = findCommand(session.getSnapshot(), match);
377
+ const node = findCommand(chatOf(session), match);
371
378
  if (node?.outcome !== null && node?.outcome !== void 0) {
372
379
  settle({ kind: node.outcome.kind, text: node.outcome.text });
373
380
  }
@@ -381,11 +388,11 @@ function isPreviewFor(node, seq) {
381
388
  const args = node.args ?? "";
382
389
  return node.name === "rewind" && args.includes("preview") && new RegExp(`(?:^|\\s)@${seq}(?=\\s|$)`).test(args);
383
390
  }
384
- async function previewImpact(session, seq) {
385
- const known = knownCommandSeqs(session, (node) => isPreviewFor(node, seq));
391
+ async function previewImpact(session, chatOf, seq) {
392
+ const known = knownCommandSeqs(session, chatOf, (node) => isPreviewFor(node, seq));
386
393
  const result = await session.command(`/rewind preview @${seq} both`);
387
394
  if (!result.ok || result.value?.matched !== true) return null;
388
- return waitForCommand(session, (node) => isPreviewFor(node, seq) && !known.has(node.seq));
395
+ return waitForCommand(session, chatOf, (node) => isPreviewFor(node, seq) && !known.has(node.seq));
389
396
  }
390
397
  function el(tag, className, text) {
391
398
  const node = document.createElement(tag);
@@ -436,7 +443,7 @@ function renderImpactStep(root, opts, back, cached) {
436
443
  root.replaceChildren(impact, actions);
437
444
  focusFirst(root);
438
445
  void (async () => {
439
- const outcome = cached ?? await previewImpact(session, seq);
446
+ const outcome = cached ?? await previewImpact(session, opts.chatOf, seq);
440
447
  if (outcome === null) {
441
448
  impact.textContent = t("popover.impact.failed", { message: "preview command failed or timed out" });
442
449
  return;
@@ -555,10 +562,10 @@ function openPopover(opts) {
555
562
  openRetractPopover(opts);
556
563
  return;
557
564
  }
558
- const { session, seq, time, preview, anchor, t } = opts;
565
+ const { session, seq, time, preview, anchor, t, chatOf } = opts;
559
566
  const onRewind = opts.onRewind;
560
567
  if (seq === void 0 || time === void 0 || onRewind === void 0) return;
561
- const durableOpts = { session, seq, time, preview, anchor, t, onRewind };
568
+ const durableOpts = { session, seq, time, preview, anchor, t, chatOf, onRewind };
562
569
  const root = el("div", CLASS.popover);
563
570
  root.setAttribute("role", "dialog");
564
571
  root.setAttribute("aria-label", t("popover.title"));
@@ -628,7 +635,7 @@ function openPopover(opts) {
628
635
  popoverEl = root;
629
636
  disposeOutside = shell.dispose;
630
637
  void (async () => {
631
- const outcome = await previewImpact(session, seq);
638
+ const outcome = await previewImpact(session, chatOf, seq);
632
639
  impactOutcome = outcome;
633
640
  if (outcome !== null && outcome.kind === "success") {
634
641
  bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
@@ -670,10 +677,10 @@ function retractSpan(steering, targetId) {
670
677
 
671
678
  // src/client/portals.tsx
672
679
  var import_jsx_runtime = require("react/jsx-runtime");
673
- function userTextAt(session, seq) {
674
- const snap = session.getSnapshot();
675
- for (const key of snap.chat.order) {
676
- const node = snap.chat.nodes.get(key);
680
+ function userTextAt(chat, seq) {
681
+ if (chat === void 0) return void 0;
682
+ for (const key of chat.order) {
683
+ const node = chat.nodes.get(key);
677
684
  if (node === void 0 || node.kind !== "user") continue;
678
685
  const user = node.data;
679
686
  if (user.seq === seq) {
@@ -691,23 +698,23 @@ function fillComposer(text) {
691
698
  textarea.focus();
692
699
  return true;
693
700
  }
694
- function userNodeOf(session, key) {
695
- const node = session.getSnapshot().chat.nodes.get(key);
701
+ function userNodeOf(chat, key) {
702
+ const node = chat?.nodes.get(key);
696
703
  if (node === void 0 || node.kind !== "user" && node.kind !== "steering") return void 0;
697
704
  return node.data;
698
705
  }
699
- async function runRewindAndFill(session, seq, mode, currentSessionId) {
700
- const known = knownCommandSeqs(session, (node) => isExecutedRewindCommand(node, seq));
706
+ async function runRewindAndFill(session, seq, mode, currentSessionId, chatOf) {
707
+ const known = knownCommandSeqs(session, chatOf, (node) => isExecutedRewindCommand(node, seq));
701
708
  const result = await session.command(`/rewind @${seq} ${mode}`);
702
709
  if (!result.ok || result.value?.matched !== true) return;
703
- const outcome = await waitForCommand(session, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
710
+ const outcome = await waitForCommand(session, chatOf, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
704
711
  if (outcome === null) return;
705
712
  if (outcome.kind !== "success") {
706
713
  showHint(outcome.text ?? "rewind failed");
707
714
  return;
708
715
  }
709
716
  if (currentSessionId() !== session.sessionId) return;
710
- const text = userTextAt(session, seq);
717
+ const text = userTextAt(chatOf(session), seq);
711
718
  if (text === void 0 || text === "") return;
712
719
  fillComposer(text);
713
720
  }
@@ -794,7 +801,7 @@ function sameTargets(left, right) {
794
801
  return other.kind === "pending" && target.itemId === other.itemId;
795
802
  });
796
803
  }
797
- function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }) {
804
+ function RewindPortals({ sessionId, sessionOf, chatOf, currentSessionId, t, subscribeLocale }) {
798
805
  const [targets, setTargets] = (0, import_react.useState)([]);
799
806
  const hidden = (0, import_react.useRef)(/* @__PURE__ */ new WeakSet());
800
807
  const [, forceRender] = (0, import_react.useReducer)((count) => count + 1, 0);
@@ -812,12 +819,12 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
812
819
  return;
813
820
  }
814
821
  const snapshot = session.getSnapshot();
815
- const chat = snapshot.chat;
816
- const hiddenSeqs = hiddenSeqsOf(chat);
822
+ const chat = chatOf(session);
823
+ const hiddenSeqs = chat === void 0 ? /* @__PURE__ */ new Set() : hiddenSeqsOf(chat);
817
824
  let hiddenCount = 0;
818
- for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
825
+ for (const seat of chat === void 0 ? [] : document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
819
826
  const key = seat.dataset.chatAnchorKey;
820
- const anchor = key !== void 0 ? chat.nodes.get(key)?.anchorSeq : void 0;
827
+ const anchor = key !== void 0 ? chat?.nodes.get(key)?.anchorSeq : void 0;
821
828
  if (anchor !== void 0 && hiddenSeqs.has(anchor)) {
822
829
  seat.style.display = "none";
823
830
  seat.dataset.dshRewindHidden = "true";
@@ -834,7 +841,8 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
834
841
  `[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
835
842
  );
836
843
  }
837
- const next = [...collectTargets(chat, hiddenSeqs), ...collectPendingTargets(snapshot)];
844
+ const durable = chat === void 0 ? [] : collectTargets(chat, hiddenSeqs);
845
+ const next = [...durable, ...collectPendingTargets(snapshot)];
838
846
  setTargets((current) => sameTargets(current, next) ? current : next);
839
847
  };
840
848
  const queueRefresh = () => {
@@ -860,6 +868,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
860
868
  target,
861
869
  sessionId,
862
870
  sessionOf,
871
+ chatOf,
863
872
  t
864
873
  },
865
874
  target.key
@@ -869,6 +878,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
869
878
  target,
870
879
  sessionId,
871
880
  sessionOf,
881
+ chatOf,
872
882
  currentSessionId,
873
883
  t
874
884
  },
@@ -878,7 +888,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
878
888
  target.key
879
889
  ));
880
890
  }
881
- function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
891
+ function RewindButton({ target, sessionId, sessionOf, chatOf, currentSessionId, t }) {
882
892
  const onClick = (event) => {
883
893
  event.stopPropagation();
884
894
  const session = sessionOf(sessionId);
@@ -886,17 +896,18 @@ function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
886
896
  console.warn("[dsh-rewind] rewind button clicked with no session binding");
887
897
  return;
888
898
  }
889
- const node = userNodeOf(session, target.key);
899
+ const node = userNodeOf(chatOf(session), target.key);
890
900
  if (node === void 0) return;
891
901
  openPopover({
892
902
  session,
903
+ chatOf,
893
904
  seq: node.seq,
894
905
  time: node.time,
895
906
  preview: messagePreviewOf(node),
896
907
  anchor: event.currentTarget,
897
908
  t,
898
909
  onRewind: (mode) => {
899
- void runRewindAndFill(session, node.seq, mode, currentSessionId);
910
+ void runRewindAndFill(session, node.seq, mode, currentSessionId, chatOf);
900
911
  }
901
912
  });
902
913
  };
@@ -927,13 +938,14 @@ async function retractPending(session, itemId, text) {
927
938
  fillComposer(text);
928
939
  }
929
940
  }
930
- function RetractButton({ target, sessionId, sessionOf, t }) {
941
+ function RetractButton({ target, sessionId, sessionOf, chatOf, t }) {
931
942
  const onClick = (event) => {
932
943
  event.stopPropagation();
933
944
  const session = sessionOf(sessionId);
934
945
  if (session === void 0) return;
935
946
  openPopover({
936
947
  session,
948
+ chatOf,
937
949
  preview: target.preview,
938
950
  anchor: event.currentTarget,
939
951
  t,
@@ -1032,6 +1044,17 @@ function apply(ctx) {
1032
1044
  const sessionOf = (sessionId) => ctx.sessions.binding(sessionId)?.session;
1033
1045
  const currentSessionId = () => ctx.sessions.list.getSnapshot().current;
1034
1046
  const subscribeLocale = (cb) => ctx.locale.subscribe(cb);
1047
+ const uiConversation = () => ctx.get("uiConversation");
1048
+ const CHAT_VIEW = "chat";
1049
+ const chatOf = (session) => {
1050
+ if (session === void 0) return void 0;
1051
+ try {
1052
+ const view = uiConversation()?.binding(session.sessionId).target(CHAT_VIEW);
1053
+ return chatSnapshotOf(session, view);
1054
+ } catch {
1055
+ return void 0;
1056
+ }
1057
+ };
1035
1058
  const slots = ctx.slots;
1036
1059
  yield slots.inject(HEADER_ACTIONS_SLOT, () => slots.register(
1037
1060
  {
@@ -1041,23 +1064,19 @@ function apply(ctx) {
1041
1064
  id: "dsh-rewind-portals",
1042
1065
  order: 1e3
1043
1066
  },
1044
- createRewindBridge({ sessionOf, currentSessionId, t, subscribeLocale })
1067
+ createRewindBridge({ sessionOf, chatOf, currentSessionId, t, subscribeLocale })
1045
1068
  ));
1046
1069
  const commandUi = ctx.get("commandUi");
1047
- const chatOf = (sessionId) => {
1048
- if (sessionId === void 0) return void 0;
1049
- const face = sessionOf(sessionId);
1050
- return face === void 0 ? void 0 : face.getSnapshot().chat;
1051
- };
1052
1070
  const hasCandidates = (sessionId) => {
1053
- const chat = chatOf(sessionId);
1071
+ const face = sessionId === void 0 ? void 0 : sessionOf(sessionId);
1072
+ const chat = chatOf(face);
1054
1073
  return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
1055
1074
  };
1056
- const fetchHostCandidates = async (face) => {
1057
- const known = knownCommandSeqs(face, (node) => isCandidateCommand(node));
1075
+ const fetchHostCandidates = async (face, chatOf2) => {
1076
+ const known = knownCommandSeqs(face, chatOf2, (node) => isCandidateCommand(node));
1058
1077
  const result = await face.command("/rewind __candidates");
1059
1078
  if (!result.ok || result.value?.matched !== true) return void 0;
1060
- const outcome = await waitForCommand(face, (node) => isCandidateCommand(node) && !known.has(node.seq));
1079
+ const outcome = await waitForCommand(face, chatOf2, (node) => isCandidateCommand(node) && !known.has(node.seq));
1061
1080
  if (outcome === null || outcome.kind !== "success" || outcome.text === void 0) return void 0;
1062
1081
  return rewindCandidatesFromHostText(outcome.text);
1063
1082
  };
@@ -1078,7 +1097,7 @@ function apply(ctx) {
1078
1097
  options: async (session) => {
1079
1098
  const face = sessionOf(session.sessionId);
1080
1099
  if (face === void 0) return [];
1081
- const candidates = await fetchHostCandidates(face);
1100
+ const candidates = await fetchHostCandidates(face, chatOf);
1082
1101
  if (candidates !== void 0) hostCandidatesCache.set(session.sessionId, candidates);
1083
1102
  return candidates === void 0 ? [] : rewindOptionsFromCandidates(candidates, t);
1084
1103
  },
@@ -1091,13 +1110,14 @@ function apply(ctx) {
1091
1110
  if (candidate === void 0) return;
1092
1111
  openPopover({
1093
1112
  session: face,
1113
+ chatOf,
1094
1114
  seq: candidate.seq,
1095
1115
  time: candidate.time,
1096
1116
  preview: candidate.preview,
1097
1117
  anchor: composerAnchor(),
1098
1118
  t,
1099
1119
  onRewind: (mode) => {
1100
- void runRewindAndFill(face, candidate.seq, mode, currentSessionId);
1120
+ void runRewindAndFill(face, candidate.seq, mode, currentSessionId, chatOf);
1101
1121
  }
1102
1122
  });
1103
1123
  }
package/lib/index.js CHANGED
@@ -772,6 +772,13 @@ var SnapshotStore = class _SnapshotStore {
772
772
  * already absent, i.e. the target state is reached), a restore is a plain
773
773
  * writeFile with a recursive mkdir of the parent. Returns how the outcome
774
774
  * should record it.
775
+ *
776
+ * This is the only place the store writes restored content to the real FS,
777
+ * and it is deliberately a raw `writeFile`/`unlink` rather than the fs
778
+ * service: the caller only ever hands it a path from `planRestore` — one the
779
+ * session's own write-class tool call recorded and resolved (never a
780
+ * symlink/hard link) and only when it differs from the live disk. So no
781
+ * arbitrary path, no model input, never automatic.
775
782
  */
776
783
  async applyActionToDisk(kind, path, content, deleteFile) {
777
784
  if (kind === "delete") {
@@ -13,6 +13,34 @@ export interface HiddenChat {
13
13
  get(key: string): ChatConversationViewNode | undefined;
14
14
  };
15
15
  }
16
+ /**
17
+ * Reader for one session's live chat snapshot. The dual channel hides the
18
+ * harness split behind SiriLee/dsh-rewind#7: rc.2 serves the chat from the
19
+ * session face snapshot, while 0.1.2-alpha.1+ serves it from the
20
+ * `uiConversation` service's named "chat" view (contributed by
21
+ * dsh-client-ui-chat through the uiSession slot hook).
22
+ */
23
+ export type ChatOf = (session: {
24
+ readonly sessionId: string;
25
+ getSnapshot(): {
26
+ chat?: unknown;
27
+ };
28
+ } | undefined) => HiddenChat | undefined;
29
+ /**
30
+ * Resolve the chat snapshot across the two harness channels: the session-face
31
+ * snapshot first (rc.2 — on alpha.1+ the face no longer carries `chat`, so the
32
+ * field reads `undefined`), then the `uiConversation` "chat" view. The view's
33
+ * `getSnapshot()` returns undefined until the named view is registered, so
34
+ * both channels missing degrades to `undefined` (no targets, no hiding —
35
+ * never a crash).
36
+ */
37
+ export declare function chatSnapshotOf(face: {
38
+ getSnapshot(): {
39
+ chat?: unknown;
40
+ };
41
+ } | undefined, chatView: {
42
+ getSnapshot(): unknown;
43
+ } | undefined): HiddenChat | undefined;
16
44
  /**
17
45
  * Extract the rewind target seq from a `/rewind` command's structured `args`
18
46
  * (e.g. `@5 chat`, `preview @5 both`). Locale-independent — never parses the
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
17
17
  import type { CommandNode } from '@deepseek-ai/dsh-client-runtime/client';
18
+ import { type ChatOf } from './hidden.ts';
18
19
  import type { RewindKey } from './locales.ts';
19
20
  type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
20
21
  export interface PopoverOptions {
@@ -31,6 +32,12 @@ export interface PopoverOptions {
31
32
  /** Pending variant: executed after the retract confirm closes the popover. */
32
33
  readonly onRetract?: () => void;
33
34
  readonly preview: string;
35
+ /**
36
+ * Dual-channel chat reader (rc.2 session face / alpha.1+ uiConversation
37
+ * view): the durable variant's command probes scan the chat through it.
38
+ * Unused by the pending-retract variant.
39
+ */
40
+ readonly chatOf: ChatOf;
34
41
  /** The button that opened the popover (outside-click ignore target). */
35
42
  readonly anchor: HTMLElement;
36
43
  readonly t: Translate;
@@ -50,13 +57,13 @@ export declare function closePopover(): void;
50
57
  * command's stale outcome (e.g. an older preview that found file changes,
51
58
  * after those changes were already restored).
52
59
  */
53
- export declare function knownCommandSeqs(session: SessionFace, match: (node: CommandNode) => boolean): Set<number>;
60
+ export declare function knownCommandSeqs(session: SessionFace, chatOf: ChatOf, match: (node: CommandNode) => boolean): Set<number>;
54
61
  /**
55
62
  * Resolve the outcome of the newest matching rewind command by watching the
56
63
  * session snapshot (command/run + command/done land as one CommandNode).
57
64
  * @returns the outcome text-bearing node, or null on timeout.
58
65
  */
59
- export declare function waitForCommand(session: SessionFace, match: (node: CommandNode) => boolean, timeoutMs?: number): Promise<{
66
+ export declare function waitForCommand(session: SessionFace, chatOf: ChatOf, match: (node: CommandNode) => boolean, timeoutMs?: number): Promise<{
60
67
  kind: 'success' | 'error';
61
68
  text?: string;
62
69
  } | null>;
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { type ReactNode } from 'react';
26
26
  import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
27
+ import { type ChatOf, type HiddenChat } from './hidden.ts';
27
28
  import type { RewindKey } from './locales.ts';
28
29
  type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
29
30
  /** One portal target: the actions row of a user/steering seat + its durable node. */
@@ -51,6 +52,12 @@ export type PortalTarget = {
51
52
  /** Capabilities the session-scoped bridge receives from the plugin apply(). */
52
53
  export interface RewindBridgeDeps {
53
54
  readonly sessionOf: (sessionId: string) => SessionFace | undefined;
55
+ /**
56
+ * Dual-channel chat reader (rc.2 session face / alpha.1+ uiConversation
57
+ * "chat" view): every chat snapshot read goes through it. See
58
+ * `chatSnapshotOf` in hidden.ts for the channel precedence.
59
+ */
60
+ readonly chatOf: ChatOf;
54
61
  readonly currentSessionId: () => string | undefined;
55
62
  readonly t: Translate;
56
63
  readonly subscribeLocale: (cb: () => void) => () => void;
@@ -87,7 +94,13 @@ export declare function fillComposer(text: string): boolean;
87
94
  * the old baseline heuristic refilled withdrawn text into the composer
88
95
  * after switching sessions or restarting dsh.
89
96
  */
90
- export declare function runRewindAndFill(session: SessionFace, seq: number, mode: 'chat' | 'both', currentSessionId: () => string | undefined): Promise<void>;
97
+ export declare function runRewindAndFill(session: SessionFace, seq: number, mode: 'chat' | 'both', currentSessionId: () => string | undefined, chatOf: ChatOf): Promise<void>;
98
+ /**
99
+ * Collect the portal targets of one session: user rows × snapshot nodes.
100
+ * Exported as a test seam — the DOM→targets pairing that drives the ↶ button
101
+ * is otherwise only reachable through a full React portal render.
102
+ */
103
+ export declare function collectTargets(chat: HiddenChat, hiddenSeqs: ReadonlySet<number>): readonly PortalTarget[];
91
104
  interface RewindPortalsProps extends RewindBridgeDeps {
92
105
  readonly sessionId: string;
93
106
  }
@@ -98,7 +111,7 @@ interface RewindPortalsProps extends RewindBridgeDeps {
98
111
  * skipped when the target set is unchanged), so the plugin never runs a
99
112
  * synchronous full-transcript scan inside a commit microtask.
100
113
  */
101
- export declare function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }: RewindPortalsProps): ReactNode;
114
+ export declare function RewindPortals({ sessionId, sessionOf, chatOf, currentSessionId, t, subscribeLocale }: RewindPortalsProps): ReactNode;
102
115
  /**
103
116
  * Build the slot-entry component for the plugin apply(): a tiny bridge that
104
117
  * injects the apply-time capabilities (session resolution, locale, rewind
@@ -456,6 +456,13 @@ export declare class SnapshotStore {
456
456
  * already absent, i.e. the target state is reached), a restore is a plain
457
457
  * writeFile with a recursive mkdir of the parent. Returns how the outcome
458
458
  * should record it.
459
+ *
460
+ * This is the only place the store writes restored content to the real FS,
461
+ * and it is deliberately a raw `writeFile`/`unlink` rather than the fs
462
+ * service: the caller only ever hands it a path from `planRestore` — one the
463
+ * session's own write-class tool call recorded and resolved (never a
464
+ * symlink/hard link) and only when it differs from the live disk. So no
465
+ * arbitrary path, no model input, never automatic.
459
466
  */
460
467
  private applyActionToDisk;
461
468
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "DSH 插件:真正便捷无感的同窗口内对话回退,从不新建分支;自带轻量工作区备份,可一并还原文件(完整 Claude Code /rewind 语义)。 · DSH plugin: genuinely effortless in-window conversation rewind — never forking a new session; ships a lightweight workspace backup that restores files together with the rewind (full Claude Code /rewind semantics).",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -154,6 +154,7 @@
154
154
  "@types/react": "^18.3.31",
155
155
  "@types/react-dom": "^18.3.7",
156
156
  "esbuild": "^0.28.2",
157
+ "jsdom": "^30.0.1",
157
158
  "react": "^18.3.1",
158
159
  "react-dom": "^18.3.1",
159
160
  "typescript": "^7.0.2",