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 +4 -1
- package/README.zh.md +4 -1
- package/lib/client.js +51 -43
- package/lib/index.js +78 -49
- package/lib/types/client/hidden.d.ts +20 -1
- package/lib/types/client/popover.d.ts +24 -0
- package/package.json +1 -1
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.
|
|
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
|
[](https://www.npmjs.com/package/dsh-rewind-plugin)
|
|
10
12
|
[](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.
|
|
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
|
[](https://www.npmjs.com/package/dsh-rewind-plugin)
|
|
10
12
|
[](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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
428
|
-
let event;
|
|
434
|
+
inflight.add(sessionId);
|
|
429
435
|
try {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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: "
|
|
437
|
-
text: `\
|
|
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
|
|
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.
|
|
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",
|