dsh-rewind-plugin 0.2.9 → 0.3.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.md +12 -9
- package/README.zh.md +12 -9
- package/assets/screenshots/rewind-candidates.png +0 -0
- package/lib/client.js +181 -35
- package/lib/index.js +312 -63
- package/lib/types/client/candidates.d.ts +76 -0
- package/lib/types/client/hidden.d.ts +10 -6
- package/lib/types/client/index.d.ts +17 -9
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/popover.d.ts +7 -0
- package/lib/types/client/portals.d.ts +22 -0
- package/lib/types/locales.d.ts +67 -0
- package/lib/types/snapshot.d.ts +107 -10
- package/package.json +13 -1
- package/assets/screenshots/guard-hint.png +0 -0
package/lib/index.js
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { createAssistantMessage } from "@deepseek-ai/dsh-llm";
|
|
3
3
|
import { unlink } from "node:fs/promises";
|
|
4
|
+
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
5
|
+
|
|
6
|
+
// src/locales.ts
|
|
7
|
+
var en = {
|
|
8
|
+
"usage.title": "Usage:",
|
|
9
|
+
"usage.noArgs": " /rewind (no args) withdraw the most recent user message",
|
|
10
|
+
"usage.seq": " /rewind @<seq> chat|both rewind to the given message (chat = conversation only / both = conversation + files)",
|
|
11
|
+
"usage.blocked": " Manual /rewind input is intercepted; use the \u21B6 button next to a message",
|
|
12
|
+
"describeTarget.seq": "seq {seq}",
|
|
13
|
+
"describeTarget.index": "message {index}",
|
|
14
|
+
"plan.rewinding": "Rewind to seq {targetSeq}, removing {count} node(s) from the model context (conversation log kept).",
|
|
15
|
+
"plan.affects": "Affects {count} file(s):",
|
|
16
|
+
"plan.restore": "restore {path}",
|
|
17
|
+
"plan.delete": "delete {path}",
|
|
18
|
+
"plan.noChanges": "No restorable changes after the target.",
|
|
19
|
+
"error.invalidTarget": 'Cannot parse target "{raw}" (expected <index> or @<seq>)',
|
|
20
|
+
"failures.suffix": "; {count} file(s) failed to restore: {list}",
|
|
21
|
+
"failures.item": "{path} ({message})",
|
|
22
|
+
"inflight": "A rewind is already running for this session; please wait.",
|
|
23
|
+
"stopFailed": "Could not stop the running agent; rewind cancelled. Please try again.",
|
|
24
|
+
"cancelled": "Rewind cancelled.",
|
|
25
|
+
"failed": "Rewind failed: {error}. The session is unchanged.",
|
|
26
|
+
"restore.count": "restored {count} file(s)",
|
|
27
|
+
"delete.count": "deleted {count} file(s)",
|
|
28
|
+
"skip.count": "skipped {count} link(s)",
|
|
29
|
+
"noRestorable": "; no restorable write-class changes after the target",
|
|
30
|
+
"success": "Withdrawn seq {targetSeq} and everything after it (conversation returned to earlier){restore}.",
|
|
31
|
+
"noUserMessages": "This session has no rewindable user messages yet.",
|
|
32
|
+
"chooseMode": "Rewind to {target}. Choose a mode:\n /rewind {target} chat conversation only\n /rewind {target} both conversation + file restore",
|
|
33
|
+
"command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)"
|
|
34
|
+
};
|
|
35
|
+
var zh = {
|
|
36
|
+
"usage.title": "\u7528\u6CD5\uFF1A",
|
|
37
|
+
"usage.noArgs": " /rewind \uFF08\u65E0\u53C2\u6570\uFF09\u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F",
|
|
38
|
+
"usage.seq": " /rewind @<seq> chat|both \u56DE\u9000\u5230\u6307\u5B9A\u6D88\u606F\uFF08chat \u4EC5\u5BF9\u8BDD / both \u5BF9\u8BDD+\u6587\u4EF6\uFF09",
|
|
39
|
+
"usage.blocked": " \u624B\u52A8\u8F93\u5165 /rewind \u4F1A\u88AB\u62E6\u622A\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684\u300C\u56DE\u9000\u300D\u6309\u94AE",
|
|
40
|
+
"describeTarget.seq": "seq {seq}",
|
|
41
|
+
"describeTarget.index": "\u7B2C {index} \u6761\u6D88\u606F",
|
|
42
|
+
"plan.rewinding": "\u5C06\u56DE\u9000\u5230 seq {targetSeq}\uFF0C\u4ECE\u6A21\u578B\u4E0A\u4E0B\u6587\u79FB\u9664 {count} \u4E2A\u8282\u70B9\uFF08\u5BF9\u8BDD\u65E5\u5FD7\u4FDD\u7559\uFF09\u3002",
|
|
43
|
+
"plan.affects": "\u5C06\u5F71\u54CD {count} \u4E2A\u6587\u4EF6\uFF1A",
|
|
44
|
+
"plan.restore": "\u8FD8\u539F {path}",
|
|
45
|
+
"plan.delete": "\u5220\u9664 {path}",
|
|
46
|
+
"plan.noChanges": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u9700\u8981\u8FD8\u539F\u7684\u53D8\u66F4\u3002",
|
|
47
|
+
"error.invalidTarget": '\u65E0\u6CD5\u89E3\u6790\u76EE\u6807 "{raw}"\uFF08\u5E94\u4E3A <\u5E8F\u53F7> \u6216 @<seq>\uFF09',
|
|
48
|
+
"failures.suffix": "\uFF1B{count} \u4E2A\u6587\u4EF6\u8FD8\u539F\u5931\u8D25\uFF1A{list}",
|
|
49
|
+
"failures.item": "{path}\uFF08{message}\uFF09",
|
|
50
|
+
"inflight": "\u8BE5\u4F1A\u8BDD\u5DF2\u6709\u4E00\u4E2A\u56DE\u9000\u6B63\u5728\u6267\u884C\uFF0C\u8BF7\u7A0D\u5019\u3002",
|
|
51
|
+
"stopFailed": "\u65E0\u6CD5\u505C\u6B62\u8FD0\u884C\u4E2D\u7684 agent\uFF0C\u56DE\u9000\u5DF2\u53D6\u6D88\u3002\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002",
|
|
52
|
+
"cancelled": "\u56DE\u9000\u5DF2\u53D6\u6D88\u3002",
|
|
53
|
+
"failed": "\u56DE\u9000\u5931\u8D25\uFF1A{error}\u3002\u4F1A\u8BDD\u672A\u6539\u53D8\u3002",
|
|
54
|
+
"restore.count": "\u8FD8\u539F {count} \u4E2A\u6587\u4EF6",
|
|
55
|
+
"delete.count": "\u5220\u9664 {count} \u4E2A\u6587\u4EF6",
|
|
56
|
+
"skip.count": "\u8DF3\u8FC7 {count} \u4E2A\u94FE\u63A5",
|
|
57
|
+
"noRestorable": "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4",
|
|
58
|
+
"success": "\u5DF2\u64A4\u56DE seq {targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09{restore}\u3002",
|
|
59
|
+
"noUserMessages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
|
|
60
|
+
"chooseMode": "\u5C06\u56DE\u9000\u5230 {target}\u3002\u9009\u62E9\u6A21\u5F0F\uFF1A\n /rewind {target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD\n /rewind {target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6",
|
|
61
|
+
"command.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"
|
|
62
|
+
};
|
|
63
|
+
var HOST_DICTS = { en, zh };
|
|
64
|
+
function translate(lang, key, params = {}) {
|
|
65
|
+
const dict = HOST_DICTS[lang] ?? en;
|
|
66
|
+
let text = dict[key] ?? key;
|
|
67
|
+
for (const [name2, value] of Object.entries(params)) {
|
|
68
|
+
text = text.split(`{${name2}}`).join(String(value));
|
|
69
|
+
}
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
4
72
|
|
|
5
73
|
// src/rewind.ts
|
|
6
74
|
var RewindError = class extends Error {
|
|
@@ -105,12 +173,24 @@ function execSessionCwd(exec, requestedPath) {
|
|
|
105
173
|
}
|
|
106
174
|
|
|
107
175
|
// src/snapshot.ts
|
|
176
|
+
import { createHash } from "node:crypto";
|
|
108
177
|
import { lstat, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
109
178
|
import { dirname, join } from "node:path";
|
|
110
179
|
import { homedir } from "node:os";
|
|
111
180
|
var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
|
|
112
181
|
var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
|
|
113
182
|
var MAX_ANCHOR_GROUPS = 100;
|
|
183
|
+
var defaultProbe = {
|
|
184
|
+
async readText(path) {
|
|
185
|
+
try {
|
|
186
|
+
return await readFile(path, "utf8");
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error.code === "ENOENT") return void 0;
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
isLink: isLinkPath
|
|
193
|
+
};
|
|
114
194
|
function safeFileId(callId) {
|
|
115
195
|
return callId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
116
196
|
}
|
|
@@ -212,43 +292,99 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
212
292
|
}
|
|
213
293
|
return earliest;
|
|
214
294
|
}
|
|
215
|
-
/** Per-file restore impact for the earliest entry at/after the target. */
|
|
216
|
-
async impactsAfter(sessionId, targetSeq) {
|
|
217
|
-
return [...(await this.earliestEntries(sessionId, targetSeq)).values()].sort((a, b) => a.path.localeCompare(b.path)).map((entry) => ({
|
|
218
|
-
path: entry.path,
|
|
219
|
-
action: entry.before === null ? "delete" : "restore"
|
|
220
|
-
}));
|
|
221
|
-
}
|
|
222
295
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
296
|
+
* The single source of truth for BOTH the impact preview and the restore
|
|
297
|
+
* pass: reconcile the earliest recorded entry per path (at/after the
|
|
298
|
+
* target) against the CURRENT on-disk state, and plan only the actions
|
|
299
|
+
* that would actually change the disk. This is the Claude Code model —
|
|
300
|
+
* `fileHistoryGetDiffStats` / `applySnapshot` both compare against the
|
|
301
|
+
* live filesystem (`checkOriginFileChanged`) and count only real
|
|
302
|
+
* differences, so a rewind whose target state already matches the disk is
|
|
303
|
+
* a no-op with zero impact.
|
|
304
|
+
*
|
|
305
|
+
* - `before === null` (the file did not exist at the target) plans a
|
|
306
|
+
* `delete` ONLY when the file currently exists; an already-absent file
|
|
307
|
+
* is a no-op — this kills the "ghost impact" of replaying an entry a
|
|
308
|
+
* previous rewind already consumed.
|
|
309
|
+
* - `before === 'X'` plans a `restore` ONLY when the current content
|
|
310
|
+
* differs from X (or the file is missing); identical content is a no-op
|
|
311
|
+
* — this keeps repeated rewinds idempotent.
|
|
312
|
+
* - Symlinked / hard-linked paths are never planned (they are reported as
|
|
313
|
+
* skipped by the restore pass, never written through).
|
|
314
|
+
* - A probe failure (e.g. a permission error reading the file) plans the
|
|
315
|
+
* action conservatively as if the file differed, so an unreadable file
|
|
316
|
+
* is never silently dropped from the restore.
|
|
317
|
+
*
|
|
318
|
+
* @param sessionId - session whose snapshot store to plan against.
|
|
319
|
+
* @param targetSeq - rewind target; entries anchored at/after it apply.
|
|
320
|
+
* @param probe - current-disk state probe (defaults to the real FS).
|
|
321
|
+
* @returns the planned actions plus the link paths that were skipped.
|
|
230
322
|
*/
|
|
231
|
-
async
|
|
232
|
-
const
|
|
233
|
-
const deleted = [];
|
|
323
|
+
async planRestore(sessionId, targetSeq, probe) {
|
|
324
|
+
const actions = [];
|
|
234
325
|
const skipped = [];
|
|
235
|
-
const failed = [];
|
|
236
326
|
for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
|
|
237
327
|
try {
|
|
238
|
-
if (await
|
|
328
|
+
if (await probe.isLink(entry.path)) {
|
|
239
329
|
skipped.push(entry.path);
|
|
240
330
|
continue;
|
|
241
331
|
}
|
|
332
|
+
const current = await probe.readText(entry.path);
|
|
242
333
|
if (entry.before === null) {
|
|
243
|
-
|
|
244
|
-
|
|
334
|
+
if (current !== void 0) actions.push({ path: entry.path, action: "delete" });
|
|
335
|
+
} else if (current !== entry.before) {
|
|
336
|
+
actions.push({ path: entry.path, action: "restore", before: entry.before });
|
|
337
|
+
}
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (entry.before === null) {
|
|
340
|
+
actions.push({ path: entry.path, action: "delete" });
|
|
341
|
+
} else {
|
|
342
|
+
actions.push({ path: entry.path, action: "restore", before: entry.before });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return { actions, skipped };
|
|
347
|
+
}
|
|
348
|
+
/** Per-file restore impact: only actions that would actually change the disk. */
|
|
349
|
+
async impactsAfter(sessionId, targetSeq, probe = defaultProbe) {
|
|
350
|
+
const { actions } = await this.planRestore(sessionId, targetSeq, probe);
|
|
351
|
+
return actions.sort((a, b) => a.path.localeCompare(b.path)).map((action) => ({ path: action.path, action: action.action }));
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Restore the workspace to the target message's checkpoint: execute exactly
|
|
355
|
+
* the actions {@link planRestore} derived from the record + current disk
|
|
356
|
+
* reconciliation — write the before content back, or delete the file when
|
|
357
|
+
* it was created after the target and still exists. Symlinked and
|
|
358
|
+
* hard-linked paths are skipped (reported, never written through); a
|
|
359
|
+
* restored file's parent directory is created when it was deleted after
|
|
360
|
+
* the backup; a delete whose file is ALREADY absent is a silent no-op (not
|
|
361
|
+
* a failure — the target state is already reached). Failures are per-file
|
|
362
|
+
* and never abort the pass.
|
|
363
|
+
*/
|
|
364
|
+
async restoreAfter(sessionId, targetSeq, deleteFile, probe = defaultProbe) {
|
|
365
|
+
const restored = [];
|
|
366
|
+
const deleted = [];
|
|
367
|
+
const skipped = [];
|
|
368
|
+
const failed = [];
|
|
369
|
+
const { actions, skipped: skippedPaths } = await this.planRestore(sessionId, targetSeq, probe);
|
|
370
|
+
skipped.push(...skippedPaths);
|
|
371
|
+
for (const action of actions) {
|
|
372
|
+
try {
|
|
373
|
+
if (action.action === "delete") {
|
|
374
|
+
try {
|
|
375
|
+
await deleteFile(action.path);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (error.code !== "ENOENT") throw error;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
deleted.push(action.path);
|
|
245
381
|
} else {
|
|
246
|
-
await mkdir(dirname(
|
|
247
|
-
await writeFile(
|
|
248
|
-
restored.push(
|
|
382
|
+
await mkdir(dirname(action.path), { recursive: true });
|
|
383
|
+
await writeFile(action.path, action.before, "utf8");
|
|
384
|
+
restored.push(action.path);
|
|
249
385
|
}
|
|
250
386
|
} catch (error) {
|
|
251
|
-
failed.push({ path:
|
|
387
|
+
failed.push({ path: action.path, message: error instanceof Error ? error.message : String(error) });
|
|
252
388
|
}
|
|
253
389
|
}
|
|
254
390
|
return { restored, deleted, skipped, failed };
|
|
@@ -283,19 +419,64 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
283
419
|
throw error;
|
|
284
420
|
}
|
|
285
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* All distinct paths ever recorded for a session — the "tracked files"
|
|
424
|
+
* set. Mirrors Claude Code's global `trackedFiles` collection (files stay
|
|
425
|
+
* tracked once a write-class tool touched them), derived from the disk
|
|
426
|
+
* entries so no extra persistence is needed.
|
|
427
|
+
*/
|
|
428
|
+
async trackedPaths(sessionId) {
|
|
429
|
+
const paths = /* @__PURE__ */ new Set();
|
|
430
|
+
for (const entry of await this.entriesAfter(sessionId, 0)) {
|
|
431
|
+
paths.add(entry.path);
|
|
432
|
+
}
|
|
433
|
+
return paths;
|
|
434
|
+
}
|
|
286
435
|
};
|
|
436
|
+
function hashPath(path) {
|
|
437
|
+
return createHash("sha256").update(path).digest("hex").slice(0, 8);
|
|
438
|
+
}
|
|
439
|
+
async function reconcileTracked(store, sessionId, anchorSeq, tracked, states, probe = defaultProbe) {
|
|
440
|
+
let recorded = 0;
|
|
441
|
+
for (const path of tracked) {
|
|
442
|
+
try {
|
|
443
|
+
if (await probe.isLink(path)) continue;
|
|
444
|
+
const current = await probe.readText(path);
|
|
445
|
+
const state = current ?? null;
|
|
446
|
+
const prev = states.get(path);
|
|
447
|
+
if (prev === void 0 || prev !== state) {
|
|
448
|
+
await store.recordEntry(sessionId, {
|
|
449
|
+
callId: `recheck-${anchorSeq}-${hashPath(path)}`,
|
|
450
|
+
anchorSeq,
|
|
451
|
+
path,
|
|
452
|
+
before: state
|
|
453
|
+
});
|
|
454
|
+
states.set(path, state);
|
|
455
|
+
recorded++;
|
|
456
|
+
}
|
|
457
|
+
} catch {
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return recorded;
|
|
461
|
+
}
|
|
287
462
|
|
|
288
463
|
// src/index.ts
|
|
289
464
|
var name = "dsh-rewind";
|
|
290
465
|
var inject = ["commands", "tools"];
|
|
291
466
|
var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_editor"]);
|
|
292
467
|
var MUTATING_EDITOR_COMMANDS = /* @__PURE__ */ new Set(["create", "str_replace", "insert"]);
|
|
293
|
-
var
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
468
|
+
var activeLocale = "en";
|
|
469
|
+
function t(key, params) {
|
|
470
|
+
return translate(activeLocale, key, params);
|
|
471
|
+
}
|
|
472
|
+
function usage() {
|
|
473
|
+
return [
|
|
474
|
+
t("usage.title"),
|
|
475
|
+
t("usage.noArgs"),
|
|
476
|
+
t("usage.seq"),
|
|
477
|
+
t("usage.blocked")
|
|
478
|
+
].join("\n");
|
|
479
|
+
}
|
|
299
480
|
function mutationPathOf(exec) {
|
|
300
481
|
const args = exec.arguments;
|
|
301
482
|
if (exec.name === "write" || exec.name === "edit") {
|
|
@@ -352,7 +533,7 @@ async function captureBefore(fs, exec, pending) {
|
|
|
352
533
|
const before = await readTextOrUndefined(fs, target, exec.signal);
|
|
353
534
|
pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, before });
|
|
354
535
|
}
|
|
355
|
-
async function commitEntry(store, pending, anchorCache, exec, result) {
|
|
536
|
+
async function commitEntry(store, pending, anchorCache, trackedBySession, exec, result) {
|
|
356
537
|
const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
|
|
357
538
|
const capture = pending.get(key);
|
|
358
539
|
if (capture === void 0) return;
|
|
@@ -368,6 +549,12 @@ async function commitEntry(store, pending, anchorCache, exec, result) {
|
|
|
368
549
|
path: capture.path,
|
|
369
550
|
before: capture.before ?? null
|
|
370
551
|
});
|
|
552
|
+
let tracked = trackedBySession.get(agent.session.id);
|
|
553
|
+
if (tracked === void 0) {
|
|
554
|
+
tracked = /* @__PURE__ */ new Set();
|
|
555
|
+
trackedBySession.set(agent.session.id, tracked);
|
|
556
|
+
}
|
|
557
|
+
tracked.add(capture.path);
|
|
371
558
|
}
|
|
372
559
|
function buildMarker() {
|
|
373
560
|
return createAssistantMessage({
|
|
@@ -376,33 +563,62 @@ function buildMarker() {
|
|
|
376
563
|
});
|
|
377
564
|
}
|
|
378
565
|
function describeTarget(target) {
|
|
379
|
-
return target.kind === "seq" ?
|
|
566
|
+
return target.kind === "seq" ? t("describeTarget.seq", { seq: target.seq }) : t("describeTarget.index", { index: target.index });
|
|
380
567
|
}
|
|
381
568
|
function formatPlan(plan, files) {
|
|
382
569
|
const lines = [
|
|
383
|
-
|
|
570
|
+
t("plan.rewinding", { targetSeq: plan.targetSeq, count: plan.shadowedSeqs.length })
|
|
384
571
|
];
|
|
385
572
|
if (files.length > 0) {
|
|
386
|
-
lines.push(
|
|
573
|
+
lines.push(t("plan.affects", { count: files.length }));
|
|
387
574
|
for (const file of files) {
|
|
388
|
-
lines.push(` ${file.action === "restore" ? "
|
|
575
|
+
lines.push(` ${file.action === "restore" ? t("plan.restore", { path: file.path }) : t("plan.delete", { path: file.path })}`);
|
|
389
576
|
}
|
|
390
577
|
} else {
|
|
391
|
-
lines.push("
|
|
578
|
+
lines.push(t("plan.noChanges"));
|
|
392
579
|
}
|
|
393
580
|
lines.push(`impact=${files.length}`);
|
|
581
|
+
for (const file of files) {
|
|
582
|
+
lines.push(`${file.action}:${file.path}`);
|
|
583
|
+
}
|
|
394
584
|
return lines.join("\n");
|
|
395
585
|
}
|
|
396
586
|
function resolveOrError(events, surface, raw) {
|
|
397
587
|
const target = parseRewindTarget(raw);
|
|
398
588
|
if (target === void 0) {
|
|
399
|
-
throw new RewindError("invalid-index",
|
|
589
|
+
throw new RewindError("invalid-index", t("error.invalidTarget", { raw }));
|
|
400
590
|
}
|
|
401
591
|
return planRewind(events, surface, target);
|
|
402
592
|
}
|
|
403
593
|
function renderFailures(failed) {
|
|
404
594
|
if (failed.length === 0) return "";
|
|
405
|
-
return
|
|
595
|
+
return t("failures.suffix", {
|
|
596
|
+
count: failed.length,
|
|
597
|
+
list: failed.map((f) => t("failures.item", { path: f.path, message: f.message })).join("\u3001")
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
async function resolveObservationTarget(fs, path) {
|
|
601
|
+
try {
|
|
602
|
+
return await fs.resolve(path);
|
|
603
|
+
} catch {
|
|
604
|
+
return void 0;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
async function syncRestoreObservations(ctx, fs, agent, outcome) {
|
|
608
|
+
if (fs === void 0) return;
|
|
609
|
+
const actor = { agent };
|
|
610
|
+
for (const path of outcome.deleted) {
|
|
611
|
+
const target = await resolveObservationTarget(fs, path);
|
|
612
|
+
if (target === void 0) continue;
|
|
613
|
+
ctx.emit("fs/observed", target, { kind: "absent" }, actor);
|
|
614
|
+
}
|
|
615
|
+
for (const path of outcome.restored) {
|
|
616
|
+
const target = await resolveObservationTarget(fs, path);
|
|
617
|
+
if (target === void 0) continue;
|
|
618
|
+
const info = await fs.stat(target);
|
|
619
|
+
if (info === void 0) continue;
|
|
620
|
+
ctx.emit("fs/observed", target, { kind: "present", version: info.version }, actor);
|
|
621
|
+
}
|
|
406
622
|
}
|
|
407
623
|
async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
|
|
408
624
|
if (signal.aborted) return false;
|
|
@@ -425,11 +641,11 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
|
|
|
425
641
|
if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
|
|
426
642
|
}
|
|
427
643
|
}
|
|
428
|
-
async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight) {
|
|
644
|
+
async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflight) {
|
|
429
645
|
const { agent } = invocation;
|
|
430
646
|
const sessionId = agent.session.id;
|
|
431
647
|
if (inflight.has(sessionId)) {
|
|
432
|
-
return { kind: "error", text: "
|
|
648
|
+
return { kind: "error", text: t("inflight") };
|
|
433
649
|
}
|
|
434
650
|
inflight.add(sessionId);
|
|
435
651
|
try {
|
|
@@ -437,11 +653,11 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
|
|
|
437
653
|
agent.cancel({ kind: "user" });
|
|
438
654
|
const stopped = await waitForAgentIdle(agent, invocation.signal);
|
|
439
655
|
if (!stopped) {
|
|
440
|
-
return { kind: "error", text: "
|
|
656
|
+
return { kind: "error", text: t("stopFailed") };
|
|
441
657
|
}
|
|
442
658
|
}
|
|
443
659
|
if (invocation.signal.aborted) {
|
|
444
|
-
return { kind: "error", text: "
|
|
660
|
+
return { kind: "error", text: t("cancelled") };
|
|
445
661
|
}
|
|
446
662
|
let plan;
|
|
447
663
|
try {
|
|
@@ -459,22 +675,23 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
|
|
|
459
675
|
} catch (error) {
|
|
460
676
|
return {
|
|
461
677
|
kind: "error",
|
|
462
|
-
text:
|
|
678
|
+
text: t("failed", { error: error instanceof Error ? error.message : String(error) })
|
|
463
679
|
};
|
|
464
680
|
}
|
|
465
681
|
let restore = "";
|
|
466
682
|
if (mode === "both") {
|
|
467
683
|
const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
|
|
684
|
+
await syncRestoreObservations(ctx, fs, agent, outcome);
|
|
468
685
|
const parts = [];
|
|
469
|
-
if (outcome.restored.length > 0) parts.push(
|
|
470
|
-
if (outcome.deleted.length > 0) parts.push(
|
|
471
|
-
if (outcome.skipped.length > 0) parts.push(
|
|
472
|
-
restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "
|
|
686
|
+
if (outcome.restored.length > 0) parts.push(t("restore.count", { count: outcome.restored.length }));
|
|
687
|
+
if (outcome.deleted.length > 0) parts.push(t("delete.count", { count: outcome.deleted.length }));
|
|
688
|
+
if (outcome.skipped.length > 0) parts.push(t("skip.count", { count: outcome.skipped.length }));
|
|
689
|
+
restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : t("noRestorable");
|
|
473
690
|
restore += renderFailures(outcome.failed);
|
|
474
691
|
}
|
|
475
692
|
return {
|
|
476
693
|
kind: "success",
|
|
477
|
-
text:
|
|
694
|
+
text: t("success", { targetSeq: plan.targetSeq, restore }),
|
|
478
695
|
sourceEventSeq: event.seq
|
|
479
696
|
};
|
|
480
697
|
} finally {
|
|
@@ -484,7 +701,7 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
|
|
|
484
701
|
function rewindErrorResult(error) {
|
|
485
702
|
if (error instanceof RewindError) {
|
|
486
703
|
const text = {
|
|
487
|
-
"no-user-messages": "
|
|
704
|
+
"no-user-messages": t("noUserMessages"),
|
|
488
705
|
"invalid-index": error.message,
|
|
489
706
|
"not-a-user-message": error.message,
|
|
490
707
|
"not-on-surface": error.message
|
|
@@ -493,20 +710,20 @@ function rewindErrorResult(error) {
|
|
|
493
710
|
}
|
|
494
711
|
throw error;
|
|
495
712
|
}
|
|
496
|
-
async function handleRewind(ctx, store, invocation, inflight) {
|
|
713
|
+
async function handleRewind(ctx, store, fs, invocation, inflight) {
|
|
497
714
|
const session = invocation.agent.session;
|
|
498
715
|
const input = invocation.rawInput.trim();
|
|
499
716
|
if (input === "") {
|
|
500
717
|
const candidates = listRewindCandidates(session.events, session.surface.nodes, 1);
|
|
501
718
|
if (candidates.length === 0) {
|
|
502
|
-
return { kind: "error", text: "
|
|
719
|
+
return { kind: "error", text: t("noUserMessages") };
|
|
503
720
|
}
|
|
504
|
-
return executeRewind(ctx, store, invocation, `@${candidates[0].seq}`, "chat", inflight);
|
|
721
|
+
return executeRewind(ctx, store, fs, invocation, `@${candidates[0].seq}`, "chat", inflight);
|
|
505
722
|
}
|
|
506
723
|
const parts = input.split(/\s+/);
|
|
507
724
|
if (parts[0] === "preview") {
|
|
508
725
|
const target2 = parts[1];
|
|
509
|
-
if (target2 === void 0) return { kind: "error", text:
|
|
726
|
+
if (target2 === void 0) return { kind: "error", text: usage() };
|
|
510
727
|
let plan;
|
|
511
728
|
try {
|
|
512
729
|
plan = resolveOrError(session.events, session.surface.nodes, target2);
|
|
@@ -519,34 +736,66 @@ async function handleRewind(ctx, store, invocation, inflight) {
|
|
|
519
736
|
const target = parts[0];
|
|
520
737
|
const mode = parts[1];
|
|
521
738
|
if (mode !== void 0 && mode !== "chat" && mode !== "both") {
|
|
522
|
-
return { kind: "error", text:
|
|
739
|
+
return { kind: "error", text: usage() };
|
|
523
740
|
}
|
|
524
741
|
if (mode === void 0) {
|
|
525
742
|
const parsed = parseRewindTarget(target);
|
|
526
|
-
if (parsed === void 0) return { kind: "error", text:
|
|
743
|
+
if (parsed === void 0) return { kind: "error", text: usage() };
|
|
527
744
|
return {
|
|
528
745
|
kind: "success",
|
|
529
|
-
text:
|
|
530
|
-
/rewind ${target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD
|
|
531
|
-
/rewind ${target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6`
|
|
746
|
+
text: t("chooseMode", { target: describeTarget(parsed) })
|
|
532
747
|
};
|
|
533
748
|
}
|
|
534
|
-
return executeRewind(ctx, store, invocation, target, mode, inflight);
|
|
749
|
+
return executeRewind(ctx, store, fs, invocation, target, mode, inflight);
|
|
535
750
|
}
|
|
536
751
|
function apply(ctx, config) {
|
|
537
752
|
const store = new SnapshotStore(config?.snapshotDir);
|
|
538
753
|
const pending = /* @__PURE__ */ new Map();
|
|
539
754
|
const anchorCache = /* @__PURE__ */ new WeakMap();
|
|
540
755
|
const inflight = /* @__PURE__ */ new Set();
|
|
756
|
+
const trackedBySession = /* @__PURE__ */ new Map();
|
|
757
|
+
const statesBySession = /* @__PURE__ */ new Map();
|
|
758
|
+
let fsService;
|
|
759
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
760
|
+
const section = settingsCtx.settings.get(settingsNamespace("locale"));
|
|
761
|
+
if (section?.preference === "zh" || section?.preference === "en") {
|
|
762
|
+
activeLocale = section.preference;
|
|
763
|
+
}
|
|
764
|
+
});
|
|
541
765
|
ctx.effect(function* () {
|
|
542
766
|
yield ctx.commands.register({
|
|
543
767
|
name: "rewind",
|
|
544
|
-
description: "
|
|
545
|
-
handler: (invocation) => handleRewind(ctx, store, invocation, inflight)
|
|
768
|
+
description: t("command.description"),
|
|
769
|
+
handler: (invocation) => handleRewind(ctx, store, fsService, invocation, inflight)
|
|
546
770
|
});
|
|
547
771
|
}, "dsh-rewind command");
|
|
772
|
+
ctx.on("session/event", (session, event) => {
|
|
773
|
+
if (event.type !== "user/message") return;
|
|
774
|
+
const header = session.header;
|
|
775
|
+
if (header.origin === "subagent" || (header.delegationDepth ?? 0) > 0) return;
|
|
776
|
+
void (async () => {
|
|
777
|
+
try {
|
|
778
|
+
const sessionId = session.id;
|
|
779
|
+
let tracked = trackedBySession.get(sessionId);
|
|
780
|
+
if (tracked === void 0) {
|
|
781
|
+
tracked = await store.trackedPaths(sessionId);
|
|
782
|
+
trackedBySession.set(sessionId, tracked);
|
|
783
|
+
}
|
|
784
|
+
if (tracked.size === 0) return;
|
|
785
|
+
let states = statesBySession.get(sessionId);
|
|
786
|
+
if (states === void 0) {
|
|
787
|
+
states = /* @__PURE__ */ new Map();
|
|
788
|
+
statesBySession.set(sessionId, states);
|
|
789
|
+
}
|
|
790
|
+
await reconcileTracked(store, sessionId, event.seq, tracked, states);
|
|
791
|
+
} catch (error) {
|
|
792
|
+
ctx.logger.warn(`[dsh-rewind] boundary re-check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
793
|
+
}
|
|
794
|
+
})();
|
|
795
|
+
}, { global: true });
|
|
548
796
|
ctx.inject(["fs"], (scope) => {
|
|
549
797
|
const fs = scope.fs;
|
|
798
|
+
fsService = fs;
|
|
550
799
|
scope.on("tools/execute", async (exec, next) => {
|
|
551
800
|
try {
|
|
552
801
|
await captureBefore(fs, exec, pending);
|
|
@@ -557,7 +806,7 @@ function apply(ctx, config) {
|
|
|
557
806
|
});
|
|
558
807
|
scope.on("tools/post-execute", async (exec, result, next) => {
|
|
559
808
|
try {
|
|
560
|
-
await commitEntry(store, pending, anchorCache, exec, result);
|
|
809
|
+
await commitEntry(store, pending, anchorCache, trackedBySession, exec, result);
|
|
561
810
|
} catch (error) {
|
|
562
811
|
ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
563
812
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure candidate computation for the `/rewind` command decoration: which user
|
|
3
|
+
* messages the harness's popupSelect shell offers, withdrawn-row exclusion,
|
|
4
|
+
* preview truncation, and the mapping to popupSelect rows. The listing is a
|
|
5
|
+
* pure function of the session chat snapshot (`rewindCandidatesOf`) so it
|
|
6
|
+
* stays unit-testable in a node environment. Surface user/steering messages
|
|
7
|
+
* only, withdrawn (hidden) rows excluded, newest first — the top row is the
|
|
8
|
+
* default highlight, i.e. the most recent message and the most common rewind
|
|
9
|
+
* target.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-rewind/client/candidates
|
|
12
|
+
*/
|
|
13
|
+
import type { SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client';
|
|
14
|
+
import type { RewindKey } from './locales.ts';
|
|
15
|
+
type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
|
|
16
|
+
/** Preview length cap for candidate rows (matches the host's candidate list). */
|
|
17
|
+
export declare const PREVIEW_CHARS = 80;
|
|
18
|
+
/** One selectable rewind target. */
|
|
19
|
+
export interface RewindCandidate {
|
|
20
|
+
/** Absolute log seq of the `user/message` event. */
|
|
21
|
+
readonly seq: number;
|
|
22
|
+
/** Unix epoch ms of the event. */
|
|
23
|
+
readonly time: number;
|
|
24
|
+
/** Truncated plain-text preview of the message content. */
|
|
25
|
+
readonly preview: string;
|
|
26
|
+
}
|
|
27
|
+
/** A chat snapshot subset the candidate listing reads (structural). */
|
|
28
|
+
export interface CandidateChat {
|
|
29
|
+
readonly order: readonly string[];
|
|
30
|
+
readonly nodes: {
|
|
31
|
+
get(key: string): CandidateUserNode | undefined;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** A user/steering row subset; only fields the listing reads are real. */
|
|
35
|
+
export interface CandidateUserNode {
|
|
36
|
+
readonly kind: string;
|
|
37
|
+
readonly anchorSeq?: number;
|
|
38
|
+
readonly data: {
|
|
39
|
+
readonly seq: number;
|
|
40
|
+
readonly time: number;
|
|
41
|
+
readonly content: readonly {
|
|
42
|
+
type: string;
|
|
43
|
+
text?: unknown;
|
|
44
|
+
}[];
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Join the text blocks of a user message into one plain preview. */
|
|
48
|
+
export declare function messagePreviewOf(message: {
|
|
49
|
+
readonly content: readonly {
|
|
50
|
+
type: string;
|
|
51
|
+
text?: unknown;
|
|
52
|
+
}[];
|
|
53
|
+
}): string;
|
|
54
|
+
/** Format a candidate row's clock time (`HH:MM`), matching the host format. */
|
|
55
|
+
export declare function formatCandidateTime(time: number): string;
|
|
56
|
+
/**
|
|
57
|
+
* List the selectable rewind candidates of a session chat snapshot: user and
|
|
58
|
+
* steering rows still on the surface (not hidden by a previous rewind), the
|
|
59
|
+
* newest `limit` kept, newest first — the top row is the default highlight,
|
|
60
|
+
* i.e. the most recent message and the most common rewind target.
|
|
61
|
+
* @param snap - the session chat snapshot.
|
|
62
|
+
* @param hidden - anchor seqs withdrawn by rewinds (from `hiddenSeqsOf`).
|
|
63
|
+
* @param limit - maximum number of candidates to return.
|
|
64
|
+
*/
|
|
65
|
+
export declare function rewindCandidatesOf(snap: CandidateChat, hidden: ReadonlySet<number>, limit?: number): RewindCandidate[];
|
|
66
|
+
/** The candidates of a live chat snapshot, withdrawn rows already excluded. */
|
|
67
|
+
export declare function rewindCandidatesOfChat(snap: CandidateChat): RewindCandidate[];
|
|
68
|
+
/**
|
|
69
|
+
* Map the candidates to popupSelect rows: the message preview as the row
|
|
70
|
+
* label (left) and the clock time as the detail (right) — the shell's native
|
|
71
|
+
* label/detail flex layout, with no recency numbers.
|
|
72
|
+
*/
|
|
73
|
+
export declare function rewindOptionsOf(snap: CandidateChat, t: Translate): SelectOption[];
|
|
74
|
+
/** Resolve one candidate by log seq (the mode popover's re-entry after a pick). */
|
|
75
|
+
export declare function candidateBySeq(snap: CandidateChat, seq: number): RewindCandidate | undefined;
|
|
76
|
+
export {};
|