@kangyishuai/dsh-session-rewind 0.2.0 → 0.4.0

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
@@ -16,11 +16,23 @@ Long conversations drift. A few turns later you wish you had answered a prompt d
16
16
 
17
17
  - **Two entries.** Press `Esc` `Esc` to open the rewind dialog, or click the ↶ button in any user message's action row (next to Copy).
18
18
  - **Running turns are not a dead end.** Confirming a rewind while a turn is running cancels that turn (keeping the queue), waits for it to go idle, then re-plans against the settled history.
19
+ - **State-change warnings.** If the withdrawn range changed session state that lives outside the transcript — plan mode, goal, todo list, model selection, permission/sandbox presets, agent preset, scheduled tasks, working directory — the dialog lists it and says that rewinding does not roll that state back.
19
20
  - **Two scopes, chosen per rewind.** The confirmation dialog offers **Rewind conversation only** (default) and **Rewind conversation and files**. The dialog first shows a dry-run preview: how many messages and roughly how many tokens will be withdrawn, which files will be restored or moved to the trash, conflicts, and skipped files.
20
21
  - **Conversation.** The target message and everything after it are withdrawn from the model-visible context and from the transcript. The session id, title and earlier history are untouched. Nothing is deleted from the log — one marker event is appended.
21
22
  - **Files (optional).** Files changed by `write` / `edit` / `apply_patch` in the withdrawn turns are restored to their pre-change content. Files created after the target are moved to the plugin's trash instead of deleted. A rescue point of the current files is created before anything is written; failures roll back from it.
22
23
  - **Composer.** The target message's text (and images) is put back into the composer, ready to edit and send again. An existing draft is never overwritten. Tick **Send this message again right after rewinding** in the confirmation dialog to retry the turn in one click (conversation-only rewinds).
23
- - **Rewind panel.** A new header action opens a panel with four tabs: **Withdrawn** (every message each rewind removed, searchable, copy or put back in the composer), **History** (audit of each rewind: scope, message count, tokens, file results), **Rescue points** (restore files from an earlier rescue), and **Settings** (default scope, page size, confirm-on-list, diagnostics).
24
+ - **Rewind panel.** A new header action opens a panel with four tabs: **Withdrawn** (every message each rewind removed, searchable, copy or put back in the composer), **History** (a timeline of each rewind and each rescue-point file restore, newest first: scope, message count, tokens, file results), **Rescue points** (restore files from an earlier rescue), and **Settings** (default scope, page size, confirm-on-list, diagnostics).
25
+
26
+ ## Read-only agent tool
27
+
28
+ The plugin also registers one **read-only** tool, `session_rewind_candidates`, so the model can reason about rewinding without performing it:
29
+
30
+ | Call | Result |
31
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
32
+ | `session_rewind_candidates {}` | Newest-first list of messages this conversation can be rewound to (`query` filters, `limit` caps at 50). |
33
+ | `session_rewind_candidates { target_seq }` | Impact preview for one message: how many messages would be withdrawn, whether an unfinished tool call would be truncated, and the target text. |
34
+
35
+ The tool never rewinds, never edits files and never appends to the session log — the rewind itself stays a user action (that is the point of the ADR 0006 design). Set `readOnlyTool: false` to unregister it; **Settings → Diagnostics** reports whether it registered.
24
36
 
25
37
  ## Install
26
38
 
package/README.zh-CN.md CHANGED
@@ -18,9 +18,21 @@
18
18
  - **每次回溯二选一**:确认框里提供「**仅回退对话**」(默认)与「**回退对话和文件**」,并先展示 dry-run 预览:将撤回多少条消息、约多少 token、哪些文件会还原/移入回收站、冲突与被跳过的文件。
19
19
  - **对话**:目标消息及其后的内容从模型上下文和渲染对话中撤回;会话 id、标题和目标之前的历史不变;日志只追加一条 marker,不删历史。
20
20
  - **文件(可选)**:被撤回轮次里由 `write` / `edit` / `apply_patch` 改过的文件还原到改动前内容;目标之后新建的文件移入插件回收站而不是删除。写入前先建救援点,失败从救援点回滚。
21
+ - **状态变更提示**:如果要撤回的区间里改过会话状态(plan 模式、goal、待办、模型选择、权限/沙箱预设、智能体预设、定时任务、工作目录),确认框会列出来并明确说明——回溯只撤回对话,**不会**回滚这些状态。
21
22
  - **运行中也能回退**:确认回退时如果这一轮正在跑,会先取消它(保留队列)、等它空闲,再对新历史重新求值。
22
23
  - **输入框**:目标消息文本(以及图片)放回输入框,改完可直接重发;已有草稿绝不覆盖。勾选「回退后立即重发」可一键重试这一轮(仅对话模式)。
23
- - **回溯面板**:会话头部新增一个入口,四个页签:**已撤回**(每次回溯撤回的全部消息,可搜索、复制、放回输入框)、**回溯记录**(审计:范围、条数、token、文件结果)、**救援点**(手动恢复文件)、**设置**(默认范围、每页数量、列表确认、诊断)。
24
+ - **回溯面板**:会话头部新增一个入口,四个页签:**已撤回**(每次回溯撤回的全部消息,可搜索、复制、放回输入框)、**回溯记录**(时间线:每次回溯与每次救援点恢复,新在上;范围、条数、token、文件结果)、**救援点**(手动恢复文件)、**设置**(默认范围、每页数量、列表确认、诊断)。
25
+
26
+ ## 只读 agent 工具
27
+
28
+ 插件还会注册一个**只读**工具 `session_rewind_candidates`,让模型能自己判断「能退回到哪、退回去会丢什么」,但**不执行**回溯:
29
+
30
+ | 调用 | 结果 |
31
+ | ------------------------------------------ | -------------------------------------------------------------------- |
32
+ | `session_rewind_candidates {}` | 按新→旧列出可回退的消息(`query` 过滤,`limit` 上限 50)。 |
33
+ | `session_rewind_candidates { target_seq }` | 单条消息的影响面:会撤回多少条、是否截断未完成的工具调用、目标文本。 |
34
+
35
+ 这个工具不回溯、不碰文件、不写会话日志——真正的回退始终由用户在界面确认(这是 ADR 0006 的设计前提)。设 `readOnlyTool: false` 可关闭;**设置 → 诊断**里能看到它是否注册成功。
24
36
 
25
37
  ## 安装
26
38
 
package/lib/client.js CHANGED
@@ -72,6 +72,20 @@ window.__ModuleLoader__.load({
72
72
  "confirm.resendHint":
73
73
  "适合「重试这一轮」:回退后自动把原文发出去,想改内容就在输入框里编辑后再发。",
74
74
  "confirm.openToolCall": "目标之后有未完成的工具调用,回溯会截断它",
75
+ "confirm.stateChanges": "这段区间里改过会话状态:{list};回溯只撤回对话,不会回滚这些状态",
76
+ "confirm.stateHint": "如需一并回退 plan/goal/待办等状态,请手动调整。",
77
+ "state.planMode": "计划模式",
78
+ "state.goal": "目标(goal)",
79
+ "state.todo": "待办清单",
80
+ "state.model": "模型选择",
81
+ "state.permission": "权限预设",
82
+ "state.sandbox": "沙箱模式",
83
+ "state.agentPreset": "智能体预设",
84
+ "state.schedule": "定时任务",
85
+ "state.workingDirectory": "工作目录",
86
+ "state.separator": "、",
87
+ "timeline.kindRescue": "文件恢复",
88
+ "timeline.rescueMeta": "从救援点恢复 {restored} 个文件 · {id}",
75
89
  "toast.done": "已回退到此处,原消息已放回输入框",
76
90
  "toast.draft": "已回退;输入框已有内容,未覆盖",
77
91
  "toast.noComposer": "已回退,但未找到输入框",
@@ -202,6 +216,21 @@ window.__ModuleLoader__.load({
202
216
  "confirm.resendHint":
203
217
  "Use this to retry a turn; to change the text, edit it in the composer instead.",
204
218
  "confirm.openToolCall": "An unfinished tool call follows the target; rewinding truncates it.",
219
+ "confirm.stateChanges":
220
+ "Session state changed inside this range: {list}. Rewinding only withdraws messages; these stay as they are.",
221
+ "confirm.stateHint": "Adjust plan/goal/todo state by hand if you need it rolled back too.",
222
+ "state.planMode": "plan mode",
223
+ "state.goal": "goal",
224
+ "state.todo": "todo list",
225
+ "state.model": "model selection",
226
+ "state.permission": "permission preset",
227
+ "state.sandbox": "sandbox mode",
228
+ "state.agentPreset": "agent preset",
229
+ "state.schedule": "scheduled task",
230
+ "state.workingDirectory": "working directory",
231
+ "state.separator": ", ",
232
+ "timeline.kindRescue": "File restore",
233
+ "timeline.rescueMeta": "Restored {restored} file(s) from rescue {id}",
205
234
  "toast.stoppedTurn": ", and the running turn was stopped",
206
235
  "toast.resent": ", and the message was sent again",
207
236
  "toast.done": "Rewound. The original message is back in the composer.",
@@ -691,7 +720,25 @@ window.__ModuleLoader__.load({
691
720
  * @param {any} value
692
721
  * @returns {any}
693
722
  */
694
- function normalizePreview(value) {
723
+ /** 会话状态变更提示(plan/goal/todo/模型…)。 @param {any} value */
724
+ function normalizeStateChanges(value) {
725
+ /** @type {any[]} */
726
+ let out = [];
727
+ let source = Array.isArray(value) ? value : [];
728
+ for (let i = 0; i < source.length; i += 1) {
729
+ let item = source[i];
730
+ if (!item || typeof item !== "object") continue;
731
+ if (typeof item.labelKey !== "string") continue;
732
+ out.push({
733
+ type: typeof item.type === "string" ? item.type : "",
734
+ labelKey: item.labelKey,
735
+ count: typeof item.count === "number" && item.count > 0 ? item.count : 1,
736
+ });
737
+ }
738
+ return out;
739
+ }
740
+
741
+ function normalizePreview(/** @type {any} */ value) {
695
742
  let source = value && typeof value === "object" ? value : {};
696
743
  let target = source.target && typeof source.target === "object" ? source.target : {};
697
744
  let chat = source.chat && typeof source.chat === "object" ? source.chat : {};
@@ -731,6 +778,7 @@ window.__ModuleLoader__.load({
731
778
  crossesOpenToolCall: chat.crossesOpenToolCall === true,
732
779
  reachable: chat.reachable !== false,
733
780
  },
781
+ stateChanges: normalizeStateChanges(source.stateChanges ?? chat.stateChanges),
734
782
  files: normalizeFiles(source.files),
735
783
  warnings: warnings,
736
784
  };
@@ -965,8 +1013,13 @@ window.__ModuleLoader__.load({
965
1013
  : typeof entry.id === "number"
966
1014
  ? String(entry.id)
967
1015
  : "";
1016
+ let action = typeof entry.action === "string" ? entry.action : "";
1017
+ let kind =
1018
+ action === "rescue-restore" ? "rescue-restore" : entry.mode === "both" ? "both" : "chat";
968
1019
  return {
969
1020
  id: id,
1021
+ action: action,
1022
+ kind: kind,
970
1023
  time: typeof entry.time === "number" ? entry.time : null,
971
1024
  targetSeq: typeof entry.targetSeq === "number" ? entry.targetSeq : null,
972
1025
  markerSeq: typeof entry.markerSeq === "number" ? entry.markerSeq : null,
@@ -989,6 +1042,12 @@ window.__ModuleLoader__.load({
989
1042
  if (entry.id === "") continue;
990
1043
  out.push(entry);
991
1044
  }
1045
+ // 时间线按时间倒序(新在上);缺时间戳的排最后。
1046
+ out.sort((/** @type {any} */ left, /** @type {any} */ right) => {
1047
+ let l = typeof left.time === "number" ? left.time : -1;
1048
+ let r = typeof right.time === "number" ? right.time : -1;
1049
+ return r - l;
1050
+ });
992
1051
  return out;
993
1052
  }
994
1053
 
@@ -1928,6 +1987,27 @@ window.__ModuleLoader__.load({
1928
1987
  h("div", { key: "tool", className: "dsh-srw-warn" }, t("confirm.openToolCall")),
1929
1988
  );
1930
1989
  }
1990
+ // 区间里改过的会话状态:回溯只切模型上下文,不会回滚这些设置。
1991
+ let stateChanges = Array.isArray(preview.stateChanges) ? preview.stateChanges : [];
1992
+ if (stateChanges.length > 0) {
1993
+ let labels = stateChanges
1994
+ .map((/** @type {any} */ item) =>
1995
+ item.count > 1 ? `${t(item.labelKey)} ×${item.count}` : t(item.labelKey),
1996
+ )
1997
+ .join(t("state.separator"));
1998
+ blocks.push(
1999
+ h(
2000
+ "div",
2001
+ { key: "state", className: "dsh-srw-warn", "data-rewind-state": "true" },
2002
+ t("confirm.stateChanges", { list: labels }),
2003
+ ),
2004
+ h(
2005
+ "div",
2006
+ { key: "state-hint", className: "dsh-srw-option-hint" },
2007
+ t("confirm.stateHint"),
2008
+ ),
2009
+ );
2010
+ }
1931
2011
  blocks.push(
1932
2012
  h(ModePicker, {
1933
2013
  key: "modes",
@@ -2307,12 +2387,14 @@ window.__ModuleLoader__.load({
2307
2387
  if (data.entries.length === 0) {
2308
2388
  return h("div", { className: "dsh-srw-dim" }, t("audit.empty"));
2309
2389
  }
2390
+ // 时间线:竖线 + 节点。回溯事件与「从救援点恢复文件」放在同一条轴上,按时间倒序。
2310
2391
  return h(
2311
2392
  "div",
2312
- { className: "dsh-srw-cuts" },
2393
+ { className: "dsh-srw-timeline" },
2313
2394
  data.entries.map((/** @type {any} */ entry) => {
2314
2395
  let expanded = data.expanded && data.expanded[entry.id] === true;
2315
2396
  let files = entry.files;
2397
+ let isRescueRestore = entry.kind === "rescue-restore";
2316
2398
  /** @type {any[]} */
2317
2399
  let pathGroups = [];
2318
2400
  if (files) {
@@ -2334,70 +2416,86 @@ window.__ModuleLoader__.load({
2334
2416
  (/** @type {any} */ sum, /** @type {any} */ group) => sum + group.paths.length,
2335
2417
  0,
2336
2418
  );
2419
+ let badge = isRescueRestore ? t("timeline.kindRescue") : t(modeLabelKey(entry.mode));
2420
+ let meta = isRescueRestore
2421
+ ? t("timeline.rescueMeta", {
2422
+ restored: files ? files.restored.length : 0,
2423
+ id: entry.rescueId || "",
2424
+ })
2425
+ : t("audit.meta", {
2426
+ mode: t(modeLabelKey(entry.mode)),
2427
+ n: entry.removedCount,
2428
+ tokens: formatCount(entry.tokens),
2429
+ });
2337
2430
  return h(
2338
2431
  "div",
2339
- { key: entry.id, className: "dsh-srw-cut" },
2432
+ {
2433
+ key: entry.id,
2434
+ className: "dsh-srw-tl-node",
2435
+ "data-kind": isRescueRestore ? "rescue-restore" : entry.mode,
2436
+ },
2437
+ h("span", { className: "dsh-srw-tl-dot", "aria-hidden": "true" }),
2340
2438
  h(
2341
2439
  "div",
2342
- { className: "dsh-srw-cut-head" },
2343
- h("span", { className: "dsh-srw-strong" }, formatDateTime(entry.time) || entry.id),
2344
- h("span", { className: "dsh-srw-badge" }, t(modeLabelKey(entry.mode))),
2440
+ { className: "dsh-srw-tl-card" },
2345
2441
  h(
2346
- "span",
2347
- { className: "dsh-srw-time" },
2348
- t("audit.meta", {
2349
- mode: t(modeLabelKey(entry.mode)),
2350
- n: entry.removedCount,
2351
- tokens: formatCount(entry.tokens),
2352
- }),
2442
+ "div",
2443
+ { className: "dsh-srw-tl-head" },
2444
+ h("span", { className: "dsh-srw-strong" }, formatDateTime(entry.time) || entry.id),
2445
+ h("span", { className: "dsh-srw-badge" }, badge),
2353
2446
  ),
2354
- ),
2355
- files
2356
- ? h(
2357
- "div",
2358
- { className: "dsh-srw-audit-files" },
2359
- t("audit.files", {
2360
- restored: files.restored.length,
2361
- trashed: files.trashed.length,
2362
- skipped: files.skipped.length,
2363
- failed: files.failed.length,
2364
- }),
2365
- )
2366
- : null,
2367
- files?.rescueId
2368
- ? h("div", { className: "dsh-srw-dim" }, t("audit.rescueId", { id: files.rescueId }))
2369
- : null,
2370
- h(
2371
- "div",
2372
- { className: "dsh-srw-item-actions dsh-srw-audit-actions" },
2373
- pathCount > 0
2447
+ h("div", { className: "dsh-srw-time" }, meta),
2448
+ files
2374
2449
  ? h(
2375
- LinkBtn,
2376
- { onClick: () => api.toggleAuditPaths(entry.id) },
2377
- expanded ? t("audit.pathsLess") : t("audit.paths", { n: pathCount }),
2450
+ "div",
2451
+ { className: "dsh-srw-audit-files" },
2452
+ t("audit.files", {
2453
+ restored: files.restored.length,
2454
+ trashed: files.trashed.length,
2455
+ skipped: files.skipped.length,
2456
+ failed: files.failed.length,
2457
+ }),
2378
2458
  )
2379
2459
  : null,
2380
- h(LinkBtn, { onClick: () => api.copyJson(entry) }, t("audit.copyJson")),
2381
- ),
2382
- expanded
2383
- ? h(
2384
- "div",
2385
- { className: "dsh-srw-paths" },
2386
- pathGroups.map((/** @type {any} */ group) =>
2387
- group.paths.map((/** @type {any} */ path) =>
2388
- h(
2389
- "div",
2390
- {
2391
- key: `${group.kind}:${path}`,
2392
- className: "dsh-srw-path",
2393
- "data-kind": group.kind,
2394
- },
2395
- h("span", { className: "dsh-srw-path-name", title: path }, path),
2460
+ files?.rescueId
2461
+ ? h(
2462
+ "div",
2463
+ { className: "dsh-srw-dim" },
2464
+ t("audit.rescueId", { id: files.rescueId }),
2465
+ )
2466
+ : null,
2467
+ h(
2468
+ "div",
2469
+ { className: "dsh-srw-item-actions dsh-srw-audit-actions" },
2470
+ pathCount > 0
2471
+ ? h(
2472
+ LinkBtn,
2473
+ { onClick: () => api.toggleAuditPaths(entry.id) },
2474
+ expanded ? t("audit.pathsLess") : t("audit.paths", { n: pathCount }),
2475
+ )
2476
+ : null,
2477
+ h(LinkBtn, { onClick: () => api.copyJson(entry) }, t("audit.copyJson")),
2478
+ ),
2479
+ expanded
2480
+ ? h(
2481
+ "div",
2482
+ { className: "dsh-srw-paths" },
2483
+ pathGroups.map((/** @type {any} */ group) =>
2484
+ group.paths.map((/** @type {any} */ path) =>
2485
+ h(
2486
+ "div",
2487
+ {
2488
+ key: `${group.kind}:${path}`,
2489
+ className: "dsh-srw-path",
2490
+ "data-kind": group.kind,
2491
+ },
2492
+ h("span", { className: "dsh-srw-path-name", title: path }, path),
2493
+ ),
2396
2494
  ),
2397
2495
  ),
2398
- ),
2399
- )
2400
- : null,
2496
+ )
2497
+ : null,
2498
+ ),
2401
2499
  );
2402
2500
  }),
2403
2501
  );
@@ -3004,6 +3102,16 @@ window.__ModuleLoader__.load({
3004
3102
  ".dsh-srw-tab[data-active=true]{color:var(--dsw-alias-label-primary,inherit);border-bottom-color:var(--dsw-alias-brand-primary,#4f7cff);}",
3005
3103
  ".dsh-srw-tab-body{display:flex;flex-direction:column;gap:10px;}",
3006
3104
  ".dsh-srw-cuts{display:flex;flex-direction:column;gap:12px;}",
3105
+ // 时间线:竖线 + 圆点 + 卡片
3106
+ ".dsh-srw-timeline{display:flex;flex-direction:column;gap:0;position:relative;}",
3107
+ ".dsh-srw-tl-node{position:relative;display:flex;gap:12px;padding:0 0 14px 0;}",
3108
+ ".dsh-srw-tl-node::before{content:'';position:absolute;left:5px;top:14px;bottom:0;width:1px;background:var(--dsw-alias-border-secondary,rgba(128,128,128,.35));}",
3109
+ ".dsh-srw-tl-node:last-child{padding-bottom:0;}",
3110
+ ".dsh-srw-tl-node:last-child::before{display:none;}",
3111
+ ".dsh-srw-tl-dot{flex:none;width:11px;height:11px;margin-top:4px;border-radius:50%;background:var(--dsw-alias-brand-primary,#4f7cff);box-shadow:0 0 0 2px var(--dsw-alias-bg-primary,transparent);}",
3112
+ ".dsh-srw-tl-node[data-kind='rescue-restore'] .dsh-srw-tl-dot{background:var(--dsw-alias-label-secondary,GrayText);}",
3113
+ ".dsh-srw-tl-card{display:flex;flex-direction:column;gap:4px;min-width:0;flex:1;}",
3114
+ ".dsh-srw-tl-head{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;}",
3007
3115
  ".dsh-srw-cut{display:flex;flex-direction:column;gap:4px;}",
3008
3116
  ".dsh-srw-cut-head{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;}",
3009
3117
  ".dsh-srw-strong{font-weight:600;}",
@@ -4426,6 +4534,7 @@ window.__ModuleLoader__.load({
4426
4534
  normalizePathList: normalizePathList,
4427
4535
  normalizeFiles: normalizeFiles,
4428
4536
  normalizePreview: normalizePreview,
4537
+ normalizeStateChanges: normalizeStateChanges,
4429
4538
  fileModeState: fileModeState,
4430
4539
  effectiveMode: effectiveMode,
4431
4540
  conflictPathsOf: conflictPathsOf,
package/lib/service.js CHANGED
@@ -29,6 +29,8 @@ import {
29
29
  } from "./operations.js";
30
30
  import { findOrphans, pruneOrphans } from "./orphans.js";
31
31
  import { cutsOf, hasOpenToolCalls, listCandidatesPage, targetContent } from "./plan.js";
32
+ import { stateChangeSummary } from "./state-changes.js";
33
+ import { describeToolImpact, installRewindTool, listToolCandidates } from "./tool.js";
32
34
 
33
35
  /**
34
36
  * 在 decorator 语法不可用(V8 装饰器关闭)的运行时里登记一个 Remote 方法。
@@ -162,6 +164,18 @@ export class SessionRewindService extends TypertRemoteService {
162
164
  this._store = null;
163
165
  this.installFileTracking();
164
166
  this.installCommand();
167
+ // 只读 agent 工具:让模型自己看清候选与影响面(绝不执行回溯)。
168
+ this.readOnlyToolInstalled = false;
169
+ if (this.config.readOnlyTool !== false) {
170
+ installRewindTool({
171
+ ctx: this.ctx,
172
+ logger: this.ctx?.logger ?? console,
173
+ host: { call: (sessionId, request) => this.toolPayload(sessionId, request) },
174
+ onInstalled: () => {
175
+ this.readOnlyToolInstalled = true;
176
+ },
177
+ });
178
+ }
165
179
  // 启动时清一次已删除会话的残留数据(失败只告警,不阻塞插件)。
166
180
  if (this.config.cleanupOrphans !== false) void this.cleanupOrphans();
167
181
  }
@@ -398,6 +412,58 @@ export class SessionRewindService extends TypertRemoteService {
398
412
  }
399
413
  }
400
414
 
415
+ /**
416
+ * 只读工具的数据面:候选列表 / 单条影响面。绝不写日志。
417
+ * @param {string} sessionId
418
+ * @param {{query?: string, limit?: number, targetSeq?: number}} [request]
419
+ * @returns {Promise<any>}
420
+ */
421
+ async toolPayload(sessionId, request) {
422
+ try {
423
+ const deps = this.deps();
424
+ const { session } = sessionEvents(deps, sessionId);
425
+ const events = session.snapshotEvents();
426
+ const nodes = Array.isArray(session.surface?.nodes) ? session.surface.nodes : [];
427
+ if (typeof request?.targetSeq === "number") {
428
+ const impact = describeToolImpact(events, nodes, request.targetSeq);
429
+ if (impact.ok !== true)
430
+ return { ok: true, value: { kind: "error", session_id: sessionId, error: impact.error } };
431
+ return {
432
+ ok: true,
433
+ value: {
434
+ kind: "impact",
435
+ session_id: sessionId,
436
+ impact: {
437
+ target_seq: impact.targetSeq,
438
+ removed_messages: impact.removedCount,
439
+ crosses_open_tool_call: impact.crossesOpenToolCall,
440
+ text: impact.text,
441
+ },
442
+ },
443
+ };
444
+ }
445
+ const listed = listToolCandidates(events, nodes, {
446
+ ...(typeof request?.query === "string" ? { query: request.query } : {}),
447
+ ...(typeof request?.limit === "number" ? { limit: request.limit } : {}),
448
+ });
449
+ return {
450
+ ok: true,
451
+ value: {
452
+ kind: "list",
453
+ session_id: sessionId,
454
+ total: listed.total,
455
+ candidates: listed.items.map((item) => ({
456
+ seq: item.seq,
457
+ ...(typeof item.time === "number" ? { time: item.time } : {}),
458
+ preview: item.preview,
459
+ })),
460
+ },
461
+ };
462
+ } catch (error) {
463
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
464
+ }
465
+ }
466
+
401
467
  /**
402
468
  * 回溯预览(dry-run):不写任何东西。
403
469
  * @param {{sessionId?: string, targetSeq?: number}} [request]
@@ -412,6 +478,8 @@ export class SessionRewindService extends TypertRemoteService {
412
478
  const tokens = this.estimateTokens(events, plan.shadowedSeqs);
413
479
  const target = targetContent(targetEvent);
414
480
  const subtitle = target.text.replace(/\s+/gu, " ").trim();
481
+ // 被撤回区间里改过的会话状态(plan/goal/todo/模型…):回溯不会回滚它们,只提示。
482
+ const stateChanges = stateChangeSummary(events, plan.surfaceStart, plan.surfaceEnd);
415
483
  /** @type {any} */
416
484
  let files = {
417
485
  available: false,
@@ -476,7 +544,9 @@ export class SessionRewindService extends TypertRemoteService {
476
544
  tokens,
477
545
  crossesOpenToolCall: hasOpenToolCalls(events, plan.targetSeq),
478
546
  reachable: true,
547
+ stateChanges,
479
548
  },
549
+ stateChanges,
480
550
  files,
481
551
  warnings,
482
552
  };
@@ -787,6 +857,37 @@ export class SessionRewindService extends TypertRemoteService {
787
857
  throw new RewindRequestError(ERROR_CODES.rescueNotFound, "a rescue id is required");
788
858
  }
789
859
  const result = await this.store.restoreRescue({ sessionId, cwd, rescueId });
860
+ // 审计:时间线里要把「从救援点恢复文件」和回溯事件放在同一条轴上。
861
+ try {
862
+ await this.store.appendAudit({
863
+ version: 1,
864
+ action: "rescue-restore",
865
+ id:
866
+ typeof this.store.newAuditId === "function"
867
+ ? this.store.newAuditId()
868
+ : `rr-${Date.now()}`,
869
+ time: Date.now(),
870
+ sessionId,
871
+ rescueId,
872
+ mode: "files",
873
+ targetSeq: null,
874
+ markerSeq: null,
875
+ removedCount: null,
876
+ tokens: null,
877
+ files: {
878
+ restored: result.restored ?? [],
879
+ trashed: [],
880
+ skipped: [],
881
+ failed: result.failed ?? [],
882
+ rescueId: result.safetyRescueId ?? null,
883
+ },
884
+ requestedBy: "ui",
885
+ });
886
+ } catch (error) {
887
+ this.ctx?.logger?.warn?.(
888
+ `session-rewind: rescue-restore audit failed: ${error instanceof Error ? error.message : String(error)}`,
889
+ );
890
+ }
790
891
  if (Array.isArray(result.failed) && result.failed.length > 0) {
791
892
  const error = new RewindRequestError(
792
893
  ERROR_CODES.filesFailed,
@@ -837,6 +938,15 @@ export class SessionRewindService extends TypertRemoteService {
837
938
  } catch (error) {
838
939
  push("审计日志", false, error instanceof Error ? error.message : String(error));
839
940
  }
941
+ push(
942
+ "只读工具",
943
+ this.config.readOnlyTool === false ? true : this.readOnlyToolInstalled === true,
944
+ this.config.readOnlyTool === false
945
+ ? "disabled by config"
946
+ : this.readOnlyToolInstalled === true
947
+ ? "enabled"
948
+ : "not registered",
949
+ );
840
950
  try {
841
951
  const orphans = await findOrphans({ dataRoot: this.dataRoot });
842
952
  const count = orphans.sessionIds.length + orphans.trashIds.length + orphans.rescueIds.length;
@@ -0,0 +1,71 @@
1
+ // @ts-check
2
+ // 「被撤回区间里改过哪些会话状态」的提示。
3
+ //
4
+ // 为什么需要:回溯只替换**模型可见的 surface**,日志与会话状态投影不动。所以区间里的
5
+ // `plan/mode`、`goal/change`、`todo/write` 这类状态事件在回溯后仍然生效——用户很容易误以为
6
+ // 「退回去就等于那些设置也退回去了」。这里只做事实提示,不做任何回滚。
7
+ //
8
+ // 未知事件类型一律忽略(AGENTS.md 约定 1:不写死内部实现,遇到不认识的保留)。
9
+
10
+ /** 已知的会话状态事件 → 文案键。 */
11
+ export const STATE_EVENT_LABELS = Object.freeze({
12
+ "plan/mode": "state.planMode",
13
+ "goal/change": "state.goal",
14
+ "todo/write": "state.todo",
15
+ "model/selection": "state.model",
16
+ "permission/preset": "state.permission",
17
+ "sandbox/mode": "state.sandbox",
18
+ "agent-preset/selected": "state.agentPreset",
19
+ "schedule/change": "state.schedule",
20
+ "working-directory/change": "state.workingDirectory",
21
+ });
22
+
23
+ /**
24
+ * @typedef {object} StateChange
25
+ * @property {string} type 事件类型
26
+ * @property {string} labelKey 文案键(`state.*`)
27
+ * @property {number} count 次数
28
+ * @property {number} firstSeq 第一次出现的 seq
29
+ */
30
+
31
+ /**
32
+ * 扫描 [fromSeq, toSeq] 区间里的会话状态事件,按出现顺序聚合。
33
+ * @param {any[]} events
34
+ * @param {number} fromSeq 含
35
+ * @param {number} toSeq 含;传 `Number.MAX_SAFE_INTEGER` 表示到日志末尾
36
+ * @returns {StateChange[]}
37
+ */
38
+ export function collectStateChanges(events, fromSeq, toSeq) {
39
+ const source = Array.isArray(events) ? events : [];
40
+ const start = typeof fromSeq === "number" ? fromSeq : 0;
41
+ const end = typeof toSeq === "number" ? toSeq : Number.MAX_SAFE_INTEGER;
42
+ /** @type {Map<string, StateChange>} */
43
+ const groups = new Map();
44
+ for (const event of source) {
45
+ if (!event || typeof event.seq !== "number") continue;
46
+ if (event.seq < start || event.seq > end) continue;
47
+ const type = event.type;
48
+ if (typeof type !== "string") continue;
49
+ const labelKey = /** @type {Record<string, string>} */ (STATE_EVENT_LABELS)[type];
50
+ if (labelKey === undefined) continue;
51
+ const existing = groups.get(type);
52
+ if (existing) existing.count += 1;
53
+ else groups.set(type, { type, labelKey, count: 1, firstSeq: event.seq });
54
+ }
55
+ return [...groups.values()].sort((left, right) => left.firstSeq - right.firstSeq);
56
+ }
57
+
58
+ /**
59
+ * 给提示用的简短摘要(不含本地化文案,只出 key 与次数)。
60
+ * @param {any[]} events
61
+ * @param {number} fromSeq
62
+ * @param {number} toSeq
63
+ * @returns {Array<{type: string, labelKey: string, count: number}>}
64
+ */
65
+ export function stateChangeSummary(events, fromSeq, toSeq) {
66
+ return collectStateChanges(events, fromSeq, toSeq).map((item) => ({
67
+ type: item.type,
68
+ labelKey: item.labelKey,
69
+ count: item.count,
70
+ }));
71
+ }
package/lib/tool.js ADDED
@@ -0,0 +1,254 @@
1
+ // @ts-check
2
+ // 只读 agent 工具 `session_rewind_candidates`:让模型自己看清「能退回到哪几步、退回去会丢什么」。
3
+ //
4
+ // 设计约束:
5
+ // - 只读:绝不写日志、绝不执行回溯。真正的回退仍由用户点确认框触发(ADR 0006)。
6
+ // - 不 import 宿主包(defineTool 在 @deepseek-ai/dsh-tools 里,插件不能依赖);这里直接
7
+ // 构造 registry 需要的普通对象,`parameters` 与 `output.schema` 都是标准 JSON Schema。
8
+ // - 形状不符就静默跳过:老版本没有 `tools` 服务时,插件其余功能照常工作。
9
+ import { hasOpenToolCalls, listCandidatesPage, planRewind } from "./plan.js";
10
+
11
+ /** 工具名(小写下划线,符合宿主 run_code 之外的自定义工具习惯)。 */
12
+ export const TOOL_NAME = "session_rewind_candidates";
13
+
14
+ /** 一次最多列多少条候选。 */
15
+ export const TOOL_MAX_LIMIT = 50;
16
+ /** 默认条数。 */
17
+ export const TOOL_DEFAULT_LIMIT = 10;
18
+
19
+ /**
20
+ * 把面向模型的省略描述压成一行。
21
+ * @param {string} text
22
+ * @param {number} max
23
+ * @returns {string}
24
+ */
25
+ function oneLine(text, max) {
26
+ const flat = String(text ?? "")
27
+ .replace(/\s+/gu, " ")
28
+ .trim();
29
+ return flat.length <= max ? flat : `${flat.slice(0, Math.max(max - 1, 1))}…`;
30
+ }
31
+
32
+ /**
33
+ * 列出可回退的候选消息。
34
+ * @param {any[]} events
35
+ * @param {number[]} surfaceNodes
36
+ * @param {{query?: string, limit?: number}} [options]
37
+ * @returns {{sessionId?: string, total: number, items: Array<{seq: number, time: number, preview: string, text: string}>}}
38
+ */
39
+ export function listToolCandidates(events, surfaceNodes, options = {}) {
40
+ const limit =
41
+ Number.isSafeInteger(options.limit) && Number(options.limit) > 0
42
+ ? Math.min(Number(options.limit), TOOL_MAX_LIMIT)
43
+ : TOOL_DEFAULT_LIMIT;
44
+ const page = listCandidatesPage(events, surfaceNodes, {
45
+ limit,
46
+ q: typeof options.query === "string" ? options.query : "",
47
+ });
48
+ return {
49
+ total: page.candidates.length,
50
+ items: page.candidates.map((candidate) => ({
51
+ seq: candidate.seq,
52
+ time: candidate.time,
53
+ preview: oneLine(candidate.preview ?? candidate.text ?? "", 160),
54
+ text: String(candidate.text ?? ""),
55
+ })),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * 评估「退回到这条消息」的影响面:撤回多少条消息、有没有未完成的工具调用。
61
+ * @param {any[]} events
62
+ * @param {number[]} surfaceNodes
63
+ * @param {number} targetSeq
64
+ * @returns {{ok: true, targetSeq: number, removedCount: number, crossesOpenToolCall: boolean, text: string} | {ok: false, error: string}}
65
+ */
66
+ export function describeToolImpact(events, surfaceNodes, targetSeq) {
67
+ const plan = planRewind(events, surfaceNodes, targetSeq);
68
+ if ("error" in plan) return { ok: false, error: String(plan.error?.code ?? plan.error) };
69
+ return {
70
+ ok: true,
71
+ targetSeq,
72
+ removedCount: plan.shadowedSeqs.length,
73
+ crossesOpenToolCall: hasOpenToolCalls(events, targetSeq),
74
+ // planRewind 已经把目标消息的文本拼好(`user/message` 的 data 本身就是 message)。
75
+ text: typeof plan.text === "string" ? plan.text : "",
76
+ };
77
+ }
78
+
79
+ /**
80
+ * 构造工具定义(不依赖宿主 defineTool)。
81
+ * @param {{call: (sessionId: string, request: any) => Promise<any>}} host
82
+ * @returns {any}
83
+ */
84
+ export function createRewindToolDefinition(host) {
85
+ return {
86
+ name: TOOL_NAME,
87
+ description:
88
+ "List the user messages this conversation can be rewound to, or preview the impact of rewinding to one of them. Read-only: it never rewinds, never edits files and never appends to the session log. The user performs the actual rewind from the UI.",
89
+ parameters: {
90
+ type: "object",
91
+ additionalProperties: false,
92
+ properties: {
93
+ query: {
94
+ type: "string",
95
+ description: "Optional case-insensitive substring filter over message text.",
96
+ },
97
+ limit: {
98
+ type: "integer",
99
+ description: `How many candidates to return (default ${TOOL_DEFAULT_LIMIT}, max ${TOOL_MAX_LIMIT}; clamped).`,
100
+ },
101
+ target_seq: {
102
+ type: "integer",
103
+ description:
104
+ "Preview the impact of rewinding to this message sequence instead of listing candidates.",
105
+ },
106
+ },
107
+ },
108
+ output: {
109
+ // 标准 JSON Schema(宿主的受支持子集):`required` 必须是字符串数组,
110
+ // 不接受 defineTool 那种 `required: true` 的属性级写法。
111
+ schema: {
112
+ type: "object",
113
+ additionalProperties: false,
114
+ required: ["kind"],
115
+ properties: {
116
+ kind: { type: "string", enum: ["list", "impact", "error"] },
117
+ session_id: { type: "string" },
118
+ total: { type: "integer" },
119
+ candidates: {
120
+ type: "array",
121
+ items: {
122
+ type: "object",
123
+ additionalProperties: false,
124
+ required: ["seq"],
125
+ properties: {
126
+ seq: { type: "integer" },
127
+ time: { type: "integer" },
128
+ preview: { type: "string" },
129
+ },
130
+ },
131
+ },
132
+ impact: {
133
+ type: "object",
134
+ additionalProperties: false,
135
+ properties: {
136
+ target_seq: { type: "integer" },
137
+ removed_messages: { type: "integer" },
138
+ crosses_open_tool_call: { type: "boolean" },
139
+ text: { type: "string" },
140
+ },
141
+ },
142
+ error: { type: "string" },
143
+ },
144
+ },
145
+ /**
146
+ * @param {any} _args
147
+ * @param {any} value
148
+ * @returns {any[]}
149
+ */
150
+ render: (_args, value) => {
151
+ const lines = [];
152
+ if (value?.error) {
153
+ lines.push(`Rewind preview failed: ${value.error}`);
154
+ } else if (value?.kind === "impact" && value.impact) {
155
+ lines.push(
156
+ `Rewinding to message #${value.impact.target_seq} would withdraw ${value.impact.removed_messages} message(s) from the model context.`,
157
+ );
158
+ if (value.impact.crosses_open_tool_call) {
159
+ lines.push(
160
+ "Warning: an unfinished tool call follows this message; rewinding truncates it.",
161
+ );
162
+ }
163
+ if (value.impact.text) lines.push(`Target message: ${oneLine(value.impact.text, 400)}`);
164
+ lines.push("The user must confirm the rewind in the UI; this tool does not perform it.");
165
+ } else {
166
+ const candidates = Array.isArray(value?.candidates) ? value.candidates : [];
167
+ if (candidates.length === 0) {
168
+ lines.push("No messages can be rewound to in this conversation.");
169
+ } else {
170
+ lines.push(
171
+ `Messages this conversation can be rewound to (${candidates.length} shown):`,
172
+ );
173
+ for (const item of candidates) {
174
+ lines.push(`- #${item.seq}: ${oneLine(item.preview, 160)}`);
175
+ }
176
+ lines.push(
177
+ "Call again with target_seq to preview the impact of one message. Rewinding itself is a user action in the UI.",
178
+ );
179
+ }
180
+ }
181
+ return [{ type: "text", text: lines.join("\n") }];
182
+ },
183
+ },
184
+ /**
185
+ * @param {any} args
186
+ * @param {any} exec
187
+ * @returns {Promise<any>}
188
+ */
189
+ async execute(args, exec) {
190
+ const sessionId = exec?.agent?.session?.id;
191
+ if (typeof sessionId !== "string" || sessionId === "") {
192
+ return { kind: "error", error: "no active session" };
193
+ }
194
+ try {
195
+ const payload = await host.call(sessionId, {
196
+ query: typeof args?.query === "string" ? args.query : undefined,
197
+ limit: typeof args?.limit === "number" ? args.limit : undefined,
198
+ targetSeq: typeof args?.target_seq === "number" ? args.target_seq : undefined,
199
+ });
200
+ if (payload?.ok !== true) {
201
+ return { kind: "error", error: String(payload?.error ?? "unavailable") };
202
+ }
203
+ return payload.value;
204
+ } catch (error) {
205
+ return {
206
+ kind: "error",
207
+ error: error instanceof Error ? error.message : String(error),
208
+ };
209
+ }
210
+ },
211
+ };
212
+ }
213
+
214
+ /**
215
+ * 注册只读工具。`tools` 服务缺失或注册失败时静默跳过。
216
+ * @param {{ctx: any, host: {call: (sessionId: string, request: any) => Promise<any>}, logger?: any, onInstalled?: () => void}} input
217
+ * @returns {boolean} 是否注册成功(`tools` 服务就绪时会返回 true)
218
+ */
219
+ export function installRewindTool(input) {
220
+ const { ctx, host, logger, onInstalled } = input;
221
+ const install = (/** @type {any} */ scope) => {
222
+ try {
223
+ const tools = scope?.tools ?? scope?.get?.("tools");
224
+ if (!tools || typeof tools.register !== "function") return;
225
+ const definition = createRewindToolDefinition(host);
226
+ const disposer = tools.register(definition);
227
+ scope?.effect?.(() => disposer, "session-rewind: read-only tool");
228
+ if (typeof onInstalled === "function") onInstalled();
229
+ return true;
230
+ } catch (error) {
231
+ logger?.warn?.(
232
+ `session-rewind: failed to register ${TOOL_NAME}: ${error instanceof Error ? error.message : String(error)}`,
233
+ );
234
+ return false;
235
+ }
236
+ };
237
+ try {
238
+ if (typeof ctx.inject === "function") {
239
+ ctx.inject(["tools"], install);
240
+ return true;
241
+ }
242
+ const tools = ctx.get?.("tools");
243
+ if (tools && typeof tools.register === "function") {
244
+ install({ tools, effect: ctx.effect?.bind(ctx) });
245
+ return true;
246
+ }
247
+ return false;
248
+ } catch (error) {
249
+ logger?.warn?.(
250
+ `session-rewind: cannot install ${TOOL_NAME}: ${error instanceof Error ? error.message : String(error)}`,
251
+ );
252
+ return false;
253
+ }
254
+ }
package/locale/en.json CHANGED
@@ -122,6 +122,20 @@
122
122
  "toast.stoppedTurn": ", and the running turn was stopped",
123
123
  "confirm.resend": "Send this message again right after rewinding",
124
124
  "confirm.resendHint": "Use this to retry a turn; to change the text, edit it in the composer instead.",
125
- "toast.resent": ", and the message was sent again"
125
+ "toast.resent": ", and the message was sent again",
126
+ "confirm.stateChanges": "Session state changed inside this range: {list}. Rewinding only withdraws messages; these stay as they are.",
127
+ "confirm.stateHint": "Adjust plan/goal/todo state by hand if you need it rolled back too.",
128
+ "state.planMode": "plan mode",
129
+ "state.goal": "goal",
130
+ "state.todo": "todo list",
131
+ "state.model": "model selection",
132
+ "state.permission": "permission preset",
133
+ "state.sandbox": "sandbox mode",
134
+ "state.agentPreset": "agent preset",
135
+ "state.schedule": "scheduled task",
136
+ "state.workingDirectory": "working directory",
137
+ "state.separator": ", ",
138
+ "timeline.kindRescue": "File restore",
139
+ "timeline.rescueMeta": "Restored {restored} file(s) from rescue {id}"
126
140
  }
127
141
  }
package/locale/zh.json CHANGED
@@ -122,6 +122,20 @@
122
122
  "toast.stoppedTurn": ",已停止运行中的回合",
123
123
  "confirm.resend": "回退后立即重发这条消息",
124
124
  "confirm.resendHint": "适合「重试这一轮」:回退后自动把原文发出去,想改内容就在输入框里编辑后再发。",
125
- "toast.resent": ",已重发这条消息"
125
+ "toast.resent": ",已重发这条消息",
126
+ "confirm.stateChanges": "这段区间里改过会话状态:{list};回溯只撤回对话,不会回滚这些状态",
127
+ "confirm.stateHint": "如需一并回退 plan/goal/待办等状态,请手动调整。",
128
+ "state.planMode": "计划模式",
129
+ "state.goal": "目标(goal)",
130
+ "state.todo": "待办清单",
131
+ "state.model": "模型选择",
132
+ "state.permission": "权限预设",
133
+ "state.sandbox": "沙箱模式",
134
+ "state.agentPreset": "智能体预设",
135
+ "state.schedule": "定时任务",
136
+ "state.workingDirectory": "工作目录",
137
+ "state.separator": "、",
138
+ "timeline.kindRescue": "文件恢复",
139
+ "timeline.rescueMeta": "从救援点恢复 {restored} 个文件 · {id}"
126
140
  }
127
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kangyishuai/dsh-session-rewind",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "In-place conversation rewind for DSH: withdraw a user message and everything after it, then refill the composer",
5
5
  "keywords": [
6
6
  "dsh",