dsh-rewind-plugin 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,6 +53,8 @@ For contributors: install from a local checkout or a pinned commit — `dsh plug
53
53
 
54
54
  **Command-line entry**: type a bare `/rewind` and press Enter to open the candidate picker; selecting a target continues the same flow as the button.
55
55
 
56
+ Both the candidate picker and the mode popover support the keyboard: ↑↓ to move, Enter to confirm, Esc to cancel/back.
57
+
56
58
  Rewinds can be repeated (each appends a marker to the log). A rewind cannot be undone through the plugin, but the withdrawn messages can be recovered by manually editing the session log. The file-restore action is not re-backed up.
57
59
 
58
60
  ## How it works
@@ -74,17 +76,16 @@ The plugin tracks the write-class tools — `write`, `edit`, `str_replace_editor
74
76
 
75
77
  1. **Before-capture** at `tools/execute` (the around-dispatch stage): the target file is read; the resolved path + content are held in a pending map. This stage runs only after any pre-execute approval gate let the call through — an `ask` short-circuit (dsh-edit-approval) **cannot skip** the backup, and a denied call never records. If the read fails (e.g. a permission error), the change is simply not backed up — the plugin warns in the log but **does not block the write**.
76
78
  2. **Disk commit** at `tools/post-execute`: the before-backup is written under the turn's anchor message seq (`~/.dsh/rewind-snapshots/<session>/<anchor seq>/<callId>.json`).
77
- 3. **Restore** (`/rewind @<seq> both`): every backup anchored at or after the target applies — modified files are written back to their **earliest** captured before-state, files created after the target are deleted, symbolic / hard links are skipped (they share an inode with another name; restoring through one would clobber both). Writes go through plain `node:fs`, independent of the fs service — under sandbox / remote backends, path resolution may be restricted.
79
+ 3. **Restore** (`/rewind @<seq> both`): every backup anchored at or after the target applies once reconciled with the current disk — modified files are written back to their **earliest** captured before-state, files created after the target are deleted, files already matching the target state are left untouched (idempotent). Symbolic / hard links are skipped (they share an inode with another name; restoring through one would clobber both). Writes go through plain `node:fs`, independent of the fs service — under sandbox / remote backends, path resolution may be restricted.
78
80
  4. A tool body that **throws** skips `tools/post-execute`; a `tools/result` safety net clears the pending capture so nothing leaks in memory.
79
81
 
80
82
  Backups persist across host restarts, bounded to the newest 100 anchor groups per session.
81
83
 
82
84
  ## What it deliberately does NOT do
83
85
 
84
- - **Whole-tree / git-first snapshots** — only write-class tool edits are backed up. `bash`, other tools, and external edits are not tracked and cannot be restored: the same limitation as Claude Code, which defers such rollbacks to the user's git.
86
+ - **Whole-tree / git-first snapshots** — only write-class tool edits plus external changes to already-tracked files are backed up; files never touched by a tool are not restored: the same limitation as Claude Code, which defers such rollbacks to the user's git.
85
87
  - **Subagent edits** — 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.
86
88
  - **Fork / branch rewind and `/compact`** — the harness already provides these ("branch in new chat", compact).
87
- - **Keyboard shortcuts** (esc+esc rewind menu) — planned as a follow-up.
88
89
 
89
90
  ## Comparison with similar projects
90
91
 
@@ -116,7 +117,7 @@ Full instructions: [docs/troubleshooting.md](docs/troubleshooting.md)
116
117
 
117
118
  ## Security
118
119
 
119
- This plugin only appends rewind-marker events to the session log; it never deletes or rewrites logged history. File writes happen only when you choose "conversation and code" — before-backups and restores stay under `~/.dsh/rewind-snapshots/`. It never touches your git repository, makes no network requests, and accesses no credentials.
120
+ This plugin only appends rewind-marker events to the session log; it never deletes or rewrites logged history. Workspace files are written only when you choose "conversation and code"; backups and restores stay under `~/.dsh/rewind-snapshots/`. It never touches your git repository, makes no network requests, and accesses no credentials.
120
121
 
121
122
  > **Note:** a rewind only hides messages from view — the exported session log (`/export`) still contains them, and this plugin cannot alter exports. To remove a conversation completely, delete its session file.
122
123
 
@@ -127,7 +128,7 @@ npm install # devDeps from the npm registry
127
128
  npm run typecheck # tsc on both compilation surfaces (host + client)
128
129
  npm test # vitest: rewind / snapshot / hidden / session-cwd / integration
129
130
  npm run build # esbuild: lib/index.js (host ESM) + lib/client.js (loader closure) + .d.ts
130
- node scripts/verify-host.mjs # boot the BUILT host artifact end-to-end (18 checks)
131
+ node scripts/verify-host.mjs # boot the BUILT host artifact end-to-end
131
132
  ```
132
133
 
133
134
  `prepare` runs the full build, so git installs and `npm pack` / `npm publish` always produce a complete `lib/` and the `LICENSE`.
package/README.zh.md CHANGED
@@ -53,6 +53,8 @@ dsh plugin --profile web add dsh-rewind-plugin
53
53
 
54
54
  **命令行入口**:输入裸 `/rewind` 回车打开候选面板,选择目标后流程与按钮一致。
55
55
 
56
+ 候选面板与模式弹层均支持键盘操作:↑↓ 移动、Enter 确认、Esc 取消/返回。
57
+
56
58
  回退可重复进行(每次追加一条标记到日志)。回退无法通过插件撤销,但可以手动编辑会话日志恢复。文件还原动作不再记录新备份。
57
59
 
58
60
  ## 原理
@@ -74,17 +76,16 @@ dsh plugin --profile web add dsh-rewind-plugin
74
76
 
75
77
  1. **写前备份**(`tools/execute`,around-dispatch 阶段):读取目标文件,把解析后的路径与内容放入 pending 表。此阶段只在任何 pre-execute 审批门放行之后运行——审批 `ask` 短路(dsh-edit-approval)**无法跳过**备份,被拒绝的调用也不会记录。若读取失败(如权限错误),该次变更直接不入备份——插件只在日志中警告,**不会阻塞写操作**。
76
78
  2. **落盘提交**(`tools/post-execute`):备份按当前轮**锚点消息 seq** 写入 `~/.dsh/rewind-snapshots/<会话>/<锚点 seq>/<callId>.json`。
77
- 3. **还原**(`/rewind @<seq> both`):锚点 ≥ 目标的每条备份生效——被修改的文件写回其**最早一次**捕获的 before 内容,目标之后新建的文件被删除,符号/硬链接跳过(它们与另一名字共享 inode,透过一个还原会误伤两个)。写入走纯 `node:fs`,不经 fs 服务——sandbox / 远程 backend 下路径解析可能受限。
79
+ 3. **还原**(`/rewind @<seq> both`):锚点 ≥ 目标的备份与磁盘**对账后生效**(改过的写回最早 before、新建的删除、一致的跳过——幂等)。符号/硬链接跳过(它们与另一名字共享 inode,透过一个还原会误伤两个)。写入走纯 `node:fs`,不经 fs 服务——sandbox / 远程 backend 下路径解析可能受限。
78
80
  4. 工具体**抛异常**会跳过 `tools/post-execute`;`tools/result` 兜底清掉 pending,避免内存泄漏。
79
81
 
80
82
  备份跨 host 重启持久化,每会话有界保留最近 100 组锚点。
81
83
 
82
84
  ## 明确不做的事
83
85
 
84
- - **整树 / git-first 快照**——只备份写类工具编辑。`bash`、其他工具与外部程序的修改不在备份内、无法还原:与 Claude Code 相同,官方同样不覆盖,此类回退交由用户 git 处理。
86
+ - **整树 / git-first 快照**——只备份写类工具编辑及已追踪文件的外部改动;从未被工具碰过的文件不还原:与 Claude Code 相同,此类回退交由用户 git 处理。
85
87
  - **子代理(subagent)的编辑**——不跟踪(同 Claude Code):子代理运行在自己的会话里,其备份无法被父会话的回退还原。
86
88
  - **fork / 分支回退与 `/compact`**——harness 已内置(「在新对话中分支」、compact)。
87
- - **快捷键**(esc+esc 打开回退菜单)——规划中的后续项。
88
89
 
89
90
  ## 与同类项目对比
90
91
 
@@ -116,7 +117,7 @@ dsh plugin --profile web add dsh-rewind-plugin
116
117
 
117
118
  ## 安全
118
119
 
119
- 本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。文件写入仅在你选择「回退对话和代码」时发生,备份与还原都限定在 `~/.dsh/rewind-snapshots/` 内。不触碰你的 git 仓库,无网络请求,不访问任何凭据。
120
+ 本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。工作区文件仅在「回退对话和代码」时被改写,备份与还原都限定在 `~/.dsh/rewind-snapshots/` 内。不触碰你的 git 仓库,无网络请求,不访问任何凭据。
120
121
 
121
122
  > **注意:** 回退只是把消息从视图中隐藏——导出的会话日志(`/export`)仍包含撤回前的内容,本插件无法改动导出。要彻底删除对话,请删除对应的会话文件。
122
123
 
@@ -127,7 +128,7 @@ npm install # devDeps 来自 npm registry
127
128
  npm run typecheck # tsc 双面编译(host + client)
128
129
  npm test # vitest:rewind / snapshot / hidden / session-cwd / 集成
129
130
  npm run build # esbuild:lib/index.js(host ESM)+ lib/client.js(loader 闭包)+ .d.ts
130
- node scripts/verify-host.mjs # 端到端验证构建产物(18 项检查)
131
+ node scripts/verify-host.mjs # 端到端验证构建产物
131
132
  ```
132
133
 
133
134
  `prepare` 执行完整构建,所以 git 安装与 `npm pack` / `npm publish` 总会产出完整的 `lib/` 与 `LICENSE`。
Binary file
Binary file
package/lib/client.js CHANGED
@@ -33,9 +33,9 @@ __export(index_exports, {
33
33
  module.exports = __toCommonJS(index_exports);
34
34
 
35
35
  // src/client/hidden.ts
36
- function targetOfOutcome(text) {
37
- if (text === void 0) return void 0;
38
- const match = text.match(/seq (\d+)/);
36
+ function targetSeqOfArgs(args) {
37
+ if (args === void 0 || args === null) return void 0;
38
+ const match = args.match(/@(\d+)/);
39
39
  return match !== null ? Number(match[1]) : void 0;
40
40
  }
41
41
  function isExecutedRewindCommand(node, seq) {
@@ -48,11 +48,14 @@ function hasFileImpact(text) {
48
48
  if (text === void 0) return true;
49
49
  const match = text.match(/impact=(\d+)/);
50
50
  if (match !== null) return Number(match[1]) > 0;
51
- return text.includes("\u5C06\u5F71\u54CD");
51
+ return false;
52
52
  }
53
53
  function isPreviewCommand(command) {
54
54
  return (command.args ?? "").includes("preview");
55
55
  }
56
+ function isCandidateCommand(command) {
57
+ return (command.args ?? "").includes("__candidates");
58
+ }
56
59
  function hiddenSeqsOf(snap) {
57
60
  const hidden = /* @__PURE__ */ new Set();
58
61
  const spans = [];
@@ -61,7 +64,7 @@ function hiddenSeqsOf(snap) {
61
64
  if (node === void 0 || node.kind !== "command") continue;
62
65
  const command = node.data;
63
66
  if (command.name !== "rewind") continue;
64
- if (isPreviewCommand(command)) {
67
+ if (isPreviewCommand(command) || isCandidateCommand(command)) {
65
68
  hidden.add(command.seq);
66
69
  continue;
67
70
  }
@@ -69,7 +72,7 @@ function hiddenSeqsOf(snap) {
69
72
  const marker = command.outcome.sourceEventSeq;
70
73
  if (marker === void 0) continue;
71
74
  hidden.add(command.seq);
72
- const target = targetOfOutcome(command.outcome.text);
75
+ const target = targetSeqOfArgs(command.args);
73
76
  if (target !== void 0) {
74
77
  spans.push({ start: target, end: marker });
75
78
  }
@@ -87,6 +90,7 @@ function hiddenSeqsOf(snap) {
87
90
 
88
91
  // src/client/candidates.ts
89
92
  var PREVIEW_CHARS = 80;
93
+ var DEFAULT_CANDIDATE_LIMIT = 50;
90
94
  function messagePreviewOf(message) {
91
95
  const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
92
96
  return text.length <= PREVIEW_CHARS ? text : `${text.slice(0, PREVIEW_CHARS - 1)}\u2026`;
@@ -97,7 +101,7 @@ function formatCandidateTime(time) {
97
101
  const mm = String(d.getMinutes()).padStart(2, "0");
98
102
  return `${hh}:${mm}`;
99
103
  }
100
- function rewindCandidatesOf(snap, hidden, limit = 10) {
104
+ function rewindCandidatesOf(snap, hidden, limit = DEFAULT_CANDIDATE_LIMIT) {
101
105
  const candidates = [];
102
106
  for (let i = snap.order.length - 1; i >= 0 && candidates.length < limit; i--) {
103
107
  const key = snap.order[i];
@@ -116,16 +120,30 @@ function rewindCandidatesOf(snap, hidden, limit = 10) {
116
120
  function rewindCandidatesOfChat(snap) {
117
121
  return rewindCandidatesOf(snap, hiddenSeqsOf(snap));
118
122
  }
119
- function rewindOptionsOf(snap, t) {
120
- return rewindCandidatesOfChat(snap).map((candidate) => ({
123
+ var CANDIDATE_LIST_HEADER = "candidates=";
124
+ function rewindCandidatesFromHostText(text) {
125
+ if (!text.startsWith(CANDIDATE_LIST_HEADER)) return [];
126
+ const lines = text.split("\n").slice(1);
127
+ const candidates = [];
128
+ for (const line of lines) {
129
+ if (line === "") continue;
130
+ const parts = line.split(" ");
131
+ if (parts.length !== 3) continue;
132
+ const seq = Number(parts[0]);
133
+ const time = Number(parts[1]);
134
+ const preview = parts[2] ?? "";
135
+ if (!Number.isSafeInteger(seq) || !Number.isFinite(time)) continue;
136
+ candidates.push({ seq, time, preview });
137
+ }
138
+ return candidates;
139
+ }
140
+ function rewindOptionsFromCandidates(candidates, t) {
141
+ return candidates.map((candidate) => ({
121
142
  id: String(candidate.seq),
122
143
  label: candidate.preview || t("popover.noText"),
123
144
  detail: formatCandidateTime(candidate.time)
124
145
  }));
125
146
  }
126
- function candidateBySeq(snap, seq) {
127
- return rewindCandidatesOfChat(snap).find((candidate) => candidate.seq === seq);
128
- }
129
147
 
130
148
  // src/client/styles.ts
131
149
  var CLASS = {
@@ -304,8 +322,14 @@ function formatTarget(t, seq, time, preview) {
304
322
  const previewText = preview.length > 0 ? preview : t("popover.noText");
305
323
  return `seq ${seq} \xB7 ${hh}:${mm} \xB7 ${previewText}`;
306
324
  }
307
- function stripImpactToken(text) {
308
- return text.replace(/\n?impact=\d+\s*$/, "");
325
+ function parseImpactList(text) {
326
+ const restores = [];
327
+ const deletes = [];
328
+ for (const line of text.split("\n")) {
329
+ if (line.startsWith("restore:")) restores.push(line.slice("restore:".length));
330
+ else if (line.startsWith("delete:")) deletes.push(line.slice("delete:".length));
331
+ }
332
+ return { restores, deletes };
309
333
  }
310
334
  function findCommand(snapshot, match) {
311
335
  let found;
@@ -419,7 +443,20 @@ function renderImpactStep(root, opts, back, cached) {
419
443
  impact.textContent = t("popover.impact.failed", { message: outcome.text ?? "unknown error" });
420
444
  return;
421
445
  }
422
- impact.textContent = outcome.text === void 0 ? t("popover.impact.none") : stripImpactToken(outcome.text);
446
+ if (outcome.text === void 0) {
447
+ impact.textContent = t("popover.impact.none");
448
+ } else {
449
+ const { restores, deletes } = parseImpactList(outcome.text);
450
+ if (restores.length === 0 && deletes.length === 0) {
451
+ impact.textContent = t("popover.impact.none");
452
+ } else {
453
+ const lines = [
454
+ ...restores.map((path) => t("popover.impact.restore", { path })),
455
+ ...deletes.map((path) => t("popover.impact.delete", { path }))
456
+ ];
457
+ impact.textContent = lines.join("\n");
458
+ }
459
+ }
423
460
  confirm.disabled = false;
424
461
  confirm.focus();
425
462
  confirm.addEventListener("click", () => {
@@ -738,6 +775,8 @@ var zh = {
738
775
  "popover.impact.loading": "\u6B63\u5728\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\u2026",
739
776
  "popover.impact.failed": "\u65E0\u6CD5\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\uFF1A{message}",
740
777
  "popover.impact.none": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u8DDF\u8E2A\u5230\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002",
778
+ "popover.impact.restore": "\u8FD8\u539F {path}",
779
+ "popover.impact.delete": "\u5220\u9664 {path}",
741
780
  "popover.confirm": "\u786E\u8BA4\u56DE\u9000",
742
781
  "popover.back": "\u8FD4\u56DE",
743
782
  "guard.hint": "/rewind \u624B\u52A8\u8F93\u5165\u4E0D\u63A5\u53D7\u53C2\u6570\uFF0C\u8BF7\u76F4\u63A5\u8F93\u5165 /rewind \u6253\u5F00\u56DE\u9000\u83DC\u5355"
@@ -757,6 +796,8 @@ var en = {
757
796
  "popover.impact.loading": "Fetching impact list\u2026",
758
797
  "popover.impact.failed": "Could not fetch the impact list: {message}",
759
798
  "popover.impact.none": "No tracked file changes after the target; nothing to restore.",
799
+ "popover.impact.restore": "Restore {path}",
800
+ "popover.impact.delete": "Delete {path}",
760
801
  "popover.confirm": "Confirm rewind",
761
802
  "popover.back": "Back",
762
803
  "guard.hint": "/rewind takes no typed arguments \u2014 enter /rewind to open the rewind picker."
@@ -800,6 +841,15 @@ function apply(ctx) {
800
841
  const chat = chatOf(sessionId);
801
842
  return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
802
843
  };
844
+ const fetchHostCandidates = async (face) => {
845
+ const known = knownCommandSeqs(face, (node) => isCandidateCommand(node));
846
+ const result = await face.command("/rewind __candidates");
847
+ if (!result.ok || result.value?.matched !== true) return void 0;
848
+ const outcome = await waitForCommand(face, (node) => isCandidateCommand(node) && !known.has(node.seq));
849
+ if (outcome === null || outcome.kind !== "success" || outcome.text === void 0) return void 0;
850
+ return rewindCandidatesFromHostText(outcome.text);
851
+ };
852
+ const hostCandidatesCache = /* @__PURE__ */ new Map();
803
853
  const composerAnchor = () => {
804
854
  const textarea = composerTextarea();
805
855
  const card = textarea?.closest("[data-composer-card]");
@@ -814,15 +864,20 @@ function apply(ctx) {
814
864
  available: (session) => hasCandidates(session.sessionId),
815
865
  ui: {
816
866
  kind: "popupSelect",
817
- options: (session) => {
818
- const chat = chatOf(session.sessionId);
819
- return Promise.resolve(chat === void 0 ? [] : rewindOptionsOf(chat, t));
867
+ options: async (session) => {
868
+ const face = sessionOf(session.sessionId);
869
+ if (face === void 0) return [];
870
+ const candidates = await fetchHostCandidates(face);
871
+ if (candidates !== void 0) hostCandidatesCache.set(session.sessionId, candidates);
872
+ return candidates === void 0 ? [] : rewindOptionsFromCandidates(candidates, t);
820
873
  },
821
874
  onSelect: (option, session) => {
822
875
  const face = sessionOf(session.sessionId);
823
- const chat = chatOf(session.sessionId);
824
- const candidate = chat !== void 0 ? candidateBySeq(chat, Number(option.id)) : void 0;
825
- if (face === void 0 || candidate === void 0) return;
876
+ if (face === void 0) return;
877
+ const candidate = hostCandidatesCache.get(session.sessionId)?.find(
878
+ (candidate2) => candidate2.seq === Number(option.id)
879
+ );
880
+ if (candidate === void 0) return;
826
881
  openPopover({
827
882
  session: face,
828
883
  seq: candidate.seq,