@kangyishuai/dsh-session-rewind 0.1.1 → 0.3.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
@@ -15,12 +15,24 @@ Unofficial plugin for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/de
15
15
  Long conversations drift. A few turns later you wish you had answered a prompt differently, but starting a new session loses everything before it, and continuing keeps the unwanted turns in the context window forever. This plugin rewinds **in place**:
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
+ - **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.
18
19
  - **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.
19
20
  - **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.
20
21
  - **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.
21
- - **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.
22
+ - **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).
22
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).
23
24
 
25
+ ## Read-only agent tool
26
+
27
+ The plugin also registers one **read-only** tool, `session_rewind_candidates`, so the model can reason about rewinding without performing it:
28
+
29
+ | Call | Result |
30
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
31
+ | `session_rewind_candidates {}` | Newest-first list of messages this conversation can be rewound to (`query` filters, `limit` caps at 50). |
32
+ | `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. |
33
+
34
+ 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.
35
+
24
36
  ## Install
25
37
 
26
38
  ```bash
@@ -49,14 +61,14 @@ Re-run `pnpm pack` + `add` after changing the code. The plugin has a host half,
49
61
 
50
62
  **What is refused**
51
63
 
52
- | Situation | What happens |
53
- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |
54
- | The session has a running turn | The host refuses (`session-rewind/busy`); stop the turn first and retry. |
55
- | The session is a subagent child | No ↶ button and no `Esc` `Esc` dialog: the Harness refuses generic session RPCs there. |
56
- | The target message was shadowed by compaction | Refused; the rewind boundary must still be in the model context. |
57
- | A file was changed outside the agent since the last recorded state | File mode is refused with a conflict list; no file is touched. |
58
- | A file is larger than 8 MiB, a symlink, or under `.git` / `node_modules` | That file is skipped and reported; other files still proceed. |
59
- | A rewind is already running on this session | Refused; one at a time. |
64
+ | Situation | What happens |
65
+ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
66
+ | The session has a running turn | It is cancelled first (queued steering messages are dropped), then the rewind runs. Set `cancelRunning: false` to refuse instead (`session-rewind/busy`). |
67
+ | The session is a subagent child | No ↶ button and no `Esc` `Esc` dialog: the Harness refuses generic session RPCs there. |
68
+ | The target message was shadowed by compaction | Refused; the rewind boundary must still be in the model context. |
69
+ | A file was changed outside the agent since the last recorded state | File mode is refused with a conflict list; no file is touched. |
70
+ | A file is larger than 8 MiB, a symlink, or under `.git` / `node_modules` | That file is skipped and reported; other files still proceed. |
71
+ | A rewind is already running on this session | Refused; one at a time. |
60
72
 
61
73
  ## File restore scope & safety
62
74
 
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
- - **输入框**:目标消息文本(以及图片)放回输入框,改完可直接重发;已有草稿绝不覆盖。
21
+ - **运行中也能回退**:确认回退时如果这一轮正在跑,会先取消它(保留队列)、等它空闲,再对新历史重新求值。
22
+ - **输入框**:目标消息文本(以及图片)放回输入框,改完可直接重发;已有草稿绝不覆盖。勾选「回退后立即重发」可一键重试这一轮(仅对话模式)。
22
23
  - **回溯面板**:会话头部新增一个入口,四个页签:**已撤回**(每次回溯撤回的全部消息,可搜索、复制、放回输入框)、**回溯记录**(审计:范围、条数、token、文件结果)、**救援点**(手动恢复文件)、**设置**(默认范围、每页数量、列表确认、诊断)。
23
24
 
25
+ ## 只读 agent 工具
26
+
27
+ 插件还会注册一个**只读**工具 `session_rewind_candidates`,让模型能自己判断「能退回到哪、退回去会丢什么」,但**不执行**回溯:
28
+
29
+ | 调用 | 结果 |
30
+ | ------------------------------------------ | -------------------------------------------------------------------- |
31
+ | `session_rewind_candidates {}` | 按新→旧列出可回退的消息(`query` 过滤,`limit` 上限 50)。 |
32
+ | `session_rewind_candidates { target_seq }` | 单条消息的影响面:会撤回多少条、是否截断未完成的工具调用、目标文本。 |
33
+
34
+ 这个工具不回溯、不碰文件、不写会话日志——真正的回退始终由用户在界面确认(这是 ADR 0006 的设计前提)。设 `readOnlyTool: false` 可关闭;**设置 → 诊断**里能看到它是否注册成功。
35
+
24
36
  ## 安装
25
37
 
26
38
  ```bash
@@ -49,14 +61,14 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
49
61
 
50
62
  **会被拒绝的情况**
51
63
 
52
- | 情况 | 表现 |
53
- | ----------------------------------------------------------- | ----------------------------------------------------------------- |
54
- | 会话有正在运行的回合 | 宿主返回 `session-rewind/busy`;先停止回合再回溯。 |
55
- | 子代理会话 | 没有 ↶ 按钮,`Esc` `Esc` 也不弹框:宿主拒绝对这类会话的通用 RPC。 |
56
- | 目标消息已被 compaction 遮蔽 | 拒绝;回溯边界必须仍在模型上下文里。 |
57
- | 文件在上次记录后被外部修改 | 文件模式整体拒绝并列出冲突文件;一个文件都不动。 |
58
- | 文件超过 8 MiB、是符号链接、或在 `.git` / `node_modules` 下 | 该文件跳过并报告,其余文件继续。 |
59
- | 同一会话已有回溯在执行 | 拒绝;一次只允许一个。 |
64
+ | 情况 | 表现 |
65
+ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
66
+ | 会话有正在运行的回合 | 先取消该回合并等到 idle(队列里未投递的插话会被丢弃),再回溯;设 `cancelRunning: false` 则仍返回 `session-rewind/busy`。 |
67
+ | 子代理会话 | 没有 ↶ 按钮,`Esc` `Esc` 也不弹框:宿主拒绝对这类会话的通用 RPC。 |
68
+ | 目标消息已被 compaction 遮蔽 | 拒绝;回溯边界必须仍在模型上下文里。 |
69
+ | 文件在上次记录后被外部修改 | 文件模式整体拒绝并列出冲突文件;一个文件都不动。 |
70
+ | 文件超过 8 MiB、是符号链接、或在 `.git` / `node_modules` 下 | 该文件跳过并报告,其余文件继续。 |
71
+ | 同一会话已有回溯在执行 | 拒绝;一次只允许一个。 |
60
72
 
61
73
  ## 文件还原的范围与安全
62
74
 
@@ -0,0 +1,108 @@
1
+ // @ts-check
2
+ // 回溯前把运行中的回合停下来:取消(保留 inbox)→ 等 idle → 丢弃尚未投递的 steering。
3
+ // 纯逻辑 + 注入 wait,便于用假 agent 单测;不 import 任何宿主包。
4
+ //
5
+ // DSH 0.2.2-rc.1 的 Agent 形状:
6
+ // - agent.status: "idle" | "running"
7
+ // - agent.cancel(cause, { keepInbox }):keepInbox 时不清空队列
8
+ // - agent.whenIdle(): Promise<void>(回合结束后 resolve)
9
+ // - agent.inbox.nextStep: 待投递的插话消息;agent.inbox.remove(id)
10
+
11
+ /** 默认等待 idle 的超时(毫秒)。 */
12
+ export const DEFAULT_CANCEL_TIMEOUT_MS = 15_000;
13
+
14
+ /**
15
+ * @typedef {object} SettleResult
16
+ * @property {boolean} cancelled 是否发起过取消
17
+ * @property {boolean} idle 是否最终空闲
18
+ * @property {string[]} droppedSteering 丢弃的插话消息 id
19
+ */
20
+
21
+ /**
22
+ * 等待 agent 空闲;优先用宿主提供的 whenIdle,否则轮询 status。
23
+ * @param {any} agent
24
+ * @param {number} timeoutMs
25
+ * @param {(ms: number) => Promise<void>} sleep
26
+ * @returns {Promise<boolean>}
27
+ */
28
+ async function waitForIdle(agent, timeoutMs, sleep) {
29
+ const timeout = new Promise((resolve) => {
30
+ void sleep(timeoutMs).then(() => resolve(false));
31
+ });
32
+ if (typeof agent?.whenIdle === "function") {
33
+ try {
34
+ const idle = Promise.resolve(agent.whenIdle()).then(
35
+ () => true,
36
+ () => false,
37
+ );
38
+ return await Promise.race([idle, timeout]);
39
+ } catch (_error) {
40
+ return false;
41
+ }
42
+ }
43
+ const deadline = Date.now() + timeoutMs;
44
+ while (Date.now() < deadline) {
45
+ if (agent?.status === "idle") return true;
46
+ await sleep(100);
47
+ }
48
+ return agent?.status === "idle";
49
+ }
50
+
51
+ /**
52
+ * 丢弃 next-step(尚未投递的插话)消息。
53
+ * @param {any} agent
54
+ * @returns {string[]}
55
+ */
56
+ function dropPendingSteering(agent) {
57
+ /** @type {string[]} */
58
+ const dropped = [];
59
+ try {
60
+ const pending = agent?.inbox?.nextStep;
61
+ if (!pending || typeof agent?.inbox?.remove !== "function") return dropped;
62
+ for (const message of [...pending]) {
63
+ if (message && typeof message.id === "string" && message.id !== "") {
64
+ try {
65
+ agent.inbox.remove(message.id);
66
+ dropped.push(message.id);
67
+ } catch (_error) {
68
+ /* 单条失败不影响其余 */
69
+ }
70
+ }
71
+ }
72
+ } catch (_error) {
73
+ /* inbox 形状不符时放弃丢弃 */
74
+ }
75
+ return dropped;
76
+ }
77
+
78
+ /**
79
+ * 停下运行中的回合并等待空闲。已经空闲时是 no-op。
80
+ * @param {any} agent
81
+ * @param {{timeoutMs?: number, sleep?: (ms: number) => Promise<void>}} [options]
82
+ * @returns {Promise<SettleResult>}
83
+ */
84
+ export async function settleAgent(agent, options = {}) {
85
+ if (!agent || agent.status === "idle")
86
+ return { cancelled: false, idle: true, droppedSteering: [] };
87
+ const timeoutMs =
88
+ typeof options.timeoutMs === "number" &&
89
+ Number.isFinite(options.timeoutMs) &&
90
+ options.timeoutMs > 0
91
+ ? options.timeoutMs
92
+ : DEFAULT_CANCEL_TIMEOUT_MS;
93
+ const sleep =
94
+ typeof options.sleep === "function"
95
+ ? options.sleep
96
+ : (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms));
97
+ try {
98
+ if (typeof agent.cancel === "function") agent.cancel({ kind: "user" }, { keepInbox: true });
99
+ } catch (_error) {
100
+ /* 取消失败时仍尝试等待 idle;超时会交给调用方报错 */
101
+ }
102
+ const idle = await waitForIdle(agent, timeoutMs, sleep);
103
+ return {
104
+ cancelled: true,
105
+ idle,
106
+ droppedSteering: idle ? dropPendingSteering(agent) : [],
107
+ };
108
+ }
package/lib/client.js CHANGED
@@ -45,6 +45,7 @@ window.__ModuleLoader__.load({
45
45
  "dialog.cancel": "取消",
46
46
  "dialog.confirm": "开始回退",
47
47
  "dialog.executing": "正在回退…",
48
+ "dialog.settling": "正在停止当前回合并回退…",
48
49
  "dialog.loading": "正在读取…",
49
50
  "dialog.empty": "没有可回溯的消息。",
50
51
  "dialog.previewLoading": "正在计算影响面…",
@@ -65,7 +66,11 @@ window.__ModuleLoader__.load({
65
66
  "confirm.pathsLess": "收起路径",
66
67
  "confirm.skipped": "{n} 个文件被跳过",
67
68
  "confirm.conflictsTitle": "冲突文件(前 5 个)",
68
- "confirm.busy": "当前回合仍在运行,请先停止后再回退",
69
+ "confirm.busy": "当前回合仍在运行:回退时会先停止它,队列中未投递的插话会被丢弃",
70
+ "confirm.settling": "正在停止当前回合…",
71
+ "confirm.resend": "回退后立即重发这条消息",
72
+ "confirm.resendHint":
73
+ "适合「重试这一轮」:回退后自动把原文发出去,想改内容就在输入框里编辑后再发。",
69
74
  "confirm.openToolCall": "目标之后有未完成的工具调用,回溯会截断它",
70
75
  "toast.done": "已回退到此处,原消息已放回输入框",
71
76
  "toast.draft": "已回退;输入框已有内容,未覆盖",
@@ -73,6 +78,8 @@ window.__ModuleLoader__.load({
73
78
  "toast.rewound": "已回退 {n} 条消息(约 {tokens} token)",
74
79
  "toast.rewoundFiles": ",还原 {restored} 个文件、移入回收站 {trashed} 个",
75
80
  "toast.rewoundFailed": ",{n} 个文件处理失败",
81
+ "toast.resent": ",已重发这条消息",
82
+ "toast.stoppedTurn": ",已停止运行中的回合",
76
83
  "toast.draftKept": ";输入框已有内容,未覆盖",
77
84
  "toast.noComposerSuffix": ";未找到输入框",
78
85
  "toast.view": "查看",
@@ -166,6 +173,7 @@ window.__ModuleLoader__.load({
166
173
  "dialog.cancel": "Cancel",
167
174
  "dialog.confirm": "Start rewind",
168
175
  "dialog.executing": "Rewinding…",
176
+ "dialog.settling": "Stopping the running turn, then rewinding…",
169
177
  "dialog.loading": "Loading…",
170
178
  "dialog.empty": "No messages to rewind to.",
171
179
  "dialog.previewLoading": "Computing the impact…",
@@ -187,8 +195,15 @@ window.__ModuleLoader__.load({
187
195
  "confirm.pathsLess": "Hide paths",
188
196
  "confirm.skipped": "{n} file(s) skipped",
189
197
  "confirm.conflictsTitle": "Conflicting files (first 5)",
190
- "confirm.busy": "The turn is still running; stop it before rewinding.",
198
+ "confirm.busy":
199
+ "The turn is still running: rewinding stops it first and drops any queued steering messages.",
200
+ "confirm.settling": "Stopping the running turn…",
201
+ "confirm.resend": "Send this message again right after rewinding",
202
+ "confirm.resendHint":
203
+ "Use this to retry a turn; to change the text, edit it in the composer instead.",
191
204
  "confirm.openToolCall": "An unfinished tool call follows the target; rewinding truncates it.",
205
+ "toast.stoppedTurn": ", and the running turn was stopped",
206
+ "toast.resent": ", and the message was sent again",
192
207
  "toast.done": "Rewound. The original message is back in the composer.",
193
208
  "toast.draft": "Rewound. The composer already had content, so it was kept.",
194
209
  "toast.noComposer": "Rewound, but no composer was found.",
@@ -1139,6 +1154,11 @@ window.__ModuleLoader__.load({
1139
1154
  text: text,
1140
1155
  images: imageRefsOf(source),
1141
1156
  files: filesReceiptOf(source.files),
1157
+ cancelledTurn: source.cancelledTurn === true,
1158
+ droppedSteering:
1159
+ typeof source.droppedSteering === "number" && source.droppedSteering > 0
1160
+ ? source.droppedSteering
1161
+ : 0,
1142
1162
  };
1143
1163
  }
1144
1164
 
@@ -1960,8 +1980,37 @@ window.__ModuleLoader__.load({
1960
1980
  }
1961
1981
  }
1962
1982
 
1983
+ // 「回退后立即重发」:仅对话模式提供(文件模式先还原文件,语义不同)。
1984
+ if (state.rewindMode !== "both" && state.previewLoading !== true) {
1985
+ blocks.push(
1986
+ h(
1987
+ "div",
1988
+ { key: "resend", className: "dsh-srw-setting" },
1989
+ h(
1990
+ "label",
1991
+ { className: "dsh-srw-check" },
1992
+ h("input", {
1993
+ type: "checkbox",
1994
+ "data-rewind-resend": "true",
1995
+ checked: state.resend === true,
1996
+ onChange: (/** @type {any} */ event) => {
1997
+ if (event?.target) api.setResend(event.target.checked === true);
1998
+ },
1999
+ }),
2000
+ h("span", null, t("confirm.resend")),
2001
+ ),
2002
+ h("div", { className: "dsh-srw-option-hint" }, t("confirm.resendHint")),
2003
+ ),
2004
+ );
2005
+ }
1963
2006
  if (state.busy) {
1964
- blocks.push(h("div", { key: "busy", className: "dsh-srw-warn" }, t("confirm.busy")));
2007
+ blocks.push(
2008
+ h(
2009
+ "div",
2010
+ { key: "busy", className: "dsh-srw-warn" },
2011
+ state.executing === true ? t("confirm.settling") : t("confirm.busy"),
2012
+ ),
2013
+ );
1965
2014
  }
1966
2015
  if (state.errorKey) {
1967
2016
  blocks.push(
@@ -2077,8 +2126,8 @@ window.__ModuleLoader__.load({
2077
2126
 
2078
2127
  let footer;
2079
2128
  if (confirmMode) {
2080
- let disabled =
2081
- state.busy === true || state.executing === true || state.previewLoading === true;
2129
+ // v2.1:运行中的回合不再阻止执行——宿主会先取消并等到 idle 再回溯。
2130
+ let disabled = state.executing === true || state.previewLoading === true;
2082
2131
  let executing = state.executing === true || state.loading === true;
2083
2132
  footer = h(
2084
2133
  Fragment,
@@ -2091,7 +2140,11 @@ window.__ModuleLoader__.load({
2091
2140
  disabled: disabled,
2092
2141
  onClick: () => api.doRewind(),
2093
2142
  },
2094
- executing ? t("dialog.executing") : t("dialog.confirm"),
2143
+ executing
2144
+ ? state.busy === true
2145
+ ? t("dialog.settling")
2146
+ : t("dialog.executing")
2147
+ : t("dialog.confirm"),
2095
2148
  ),
2096
2149
  );
2097
2150
  } else {
@@ -2720,7 +2773,7 @@ window.__ModuleLoader__.load({
2720
2773
  } else if (state.mode === "confirm" && event.key === "Enter") {
2721
2774
  event.preventDefault();
2722
2775
  event.stopPropagation();
2723
- if (!state.busy && !state.executing && !state.previewLoading) api.doRewind();
2776
+ if (!state.executing && !state.previewLoading) api.doRewind();
2724
2777
  }
2725
2778
  return;
2726
2779
  }
@@ -3059,6 +3112,7 @@ window.__ModuleLoader__.load({
3059
3112
  previewErrorParams: null,
3060
3113
  rewindMode: "chat",
3061
3114
  showAllPaths: false,
3115
+ resend: false,
3062
3116
  busy: false,
3063
3117
  executing: false,
3064
3118
  loading: false,
@@ -3182,6 +3236,32 @@ window.__ModuleLoader__.load({
3182
3236
  }
3183
3237
 
3184
3238
  /** 回填输入框;返回 'ok' | 'draft' | 'none'。 */
3239
+ /** 复用刚回填的 draft 直接提交(「回退后立即重发」)。 @param {any} sessionId */
3240
+ function submitComposer(/** @type {any} */ sessionId) {
3241
+ try {
3242
+ let conversation = safeGet(ctx, "conversation");
3243
+ let scope =
3244
+ ctx.sessions && typeof ctx.sessions.scope === "function"
3245
+ ? ctx.sessions.scope(sessionId)
3246
+ : undefined;
3247
+ if (!conversation?.input || typeof conversation.input.for !== "function") return false;
3248
+ if (scope === undefined) return false;
3249
+ let facade = conversation.input.for(scope);
3250
+ if (!facade) return false;
3251
+ if (facade.actions && typeof facade.actions.submit === "function") {
3252
+ facade.actions.submit();
3253
+ return true;
3254
+ }
3255
+ if (typeof facade.submit === "function") {
3256
+ facade.submit("queue");
3257
+ return true;
3258
+ }
3259
+ return false;
3260
+ } catch (_error) {
3261
+ return false;
3262
+ }
3263
+ }
3264
+
3185
3265
  function fillComposer(/** @type {any} */ sessionId, /** @type {any} */ text) {
3186
3266
  let facade;
3187
3267
  try {
@@ -3298,6 +3378,7 @@ window.__ModuleLoader__.load({
3298
3378
  previewLoading: false,
3299
3379
  previewErrorKey: null,
3300
3380
  previewErrorParams: null,
3381
+ resend: false,
3301
3382
  busy: false,
3302
3383
  executing: false,
3303
3384
  loading: false,
@@ -3393,6 +3474,7 @@ window.__ModuleLoader__.load({
3393
3474
  previewErrorParams: null,
3394
3475
  rewindMode: effectiveMode(prefs.defaultMode, null),
3395
3476
  showAllPaths: false,
3477
+ resend: false,
3396
3478
  busy: busy,
3397
3479
  executing: false,
3398
3480
  loading: false,
@@ -3435,6 +3517,11 @@ window.__ModuleLoader__.load({
3435
3517
  }
3436
3518
 
3437
3519
  /** 确认框里切换回退范围。 */
3520
+ /** 「回退后立即重发」勾选。 @param {boolean} value */
3521
+ function setResend(/** @type {any} */ value) {
3522
+ patch({ resend: value === true });
3523
+ }
3524
+
3438
3525
  function setRewindMode(/** @type {any} */ mode) {
3439
3526
  patch({ rewindMode: mode === "both" ? "both" : "chat" });
3440
3527
  }
@@ -3461,6 +3548,7 @@ window.__ModuleLoader__.load({
3461
3548
  previewLoading: false,
3462
3549
  previewErrorKey: null,
3463
3550
  previewErrorParams: null,
3551
+ resend: false,
3464
3552
  busy: false,
3465
3553
  executing: false,
3466
3554
  loading: false,
@@ -3479,6 +3567,7 @@ window.__ModuleLoader__.load({
3479
3567
  previewLoading: false,
3480
3568
  previewErrorKey: null,
3481
3569
  previewErrorParams: null,
3570
+ resend: false,
3482
3571
  busy: false,
3483
3572
  executing: false,
3484
3573
  loading: false,
@@ -3531,6 +3620,8 @@ window.__ModuleLoader__.load({
3531
3620
  let state = store.getSnapshot();
3532
3621
  if (state?.executing) return;
3533
3622
  let wasConfirm = state?.mode === "confirm";
3623
+ // 仅对话模式下才有「回退后立即重发」。
3624
+ let resend = wasConfirm && state?.resend === true && mode !== "both";
3534
3625
  /** @type {any} */
3535
3626
  let busyState = {
3536
3627
  origin: origin,
@@ -3550,7 +3641,7 @@ window.__ModuleLoader__.load({
3550
3641
  mode: mode === "both" ? "both" : "chat",
3551
3642
  })
3552
3643
  .then((/** @type {any} */ value) => {
3553
- handleRewindSuccess(sessionId, target, value);
3644
+ handleRewindSuccess(sessionId, target, value, resend);
3554
3645
  })
3555
3646
  .catch((/** @type {any} */ error) => {
3556
3647
  handleRewindFailure(sessionId, target, origin, wasConfirm, error);
@@ -3562,6 +3653,7 @@ window.__ModuleLoader__.load({
3562
3653
  /** @type {any} */ sessionId,
3563
3654
  /** @type {any} */ target,
3564
3655
  /** @type {any} */ value,
3656
+ /** @type {boolean} */ resend,
3565
3657
  ) {
3566
3658
  let summary = rewindSummaryOf(value);
3567
3659
  cutStore.add(sessionId, {
@@ -3583,6 +3675,12 @@ window.__ModuleLoader__.load({
3583
3675
  text += t("toast.rewoundFailed", { n: summary.files.failed.length });
3584
3676
  }
3585
3677
  }
3678
+ if (summary.cancelledTurn) text += t("toast.stoppedTurn");
3679
+ let resent = false;
3680
+ if (resend === true && outcome === "ok") {
3681
+ resent = submitComposer(sessionId);
3682
+ if (resent) text += t("toast.resent");
3683
+ }
3586
3684
  if (outcome === "draft") text += t("toast.draftKept");
3587
3685
  else if (outcome === "none") text += t("toast.noComposerSuffix");
3588
3686
  patch({
@@ -3593,6 +3691,7 @@ window.__ModuleLoader__.load({
3593
3691
  previewLoading: false,
3594
3692
  previewErrorKey: null,
3595
3693
  previewErrorParams: null,
3694
+ resend: false,
3596
3695
  busy: false,
3597
3696
  executing: false,
3598
3697
  loading: false,
@@ -3655,8 +3754,8 @@ window.__ModuleLoader__.load({
3655
3754
  function doRewind() {
3656
3755
  let state = store.getSnapshot();
3657
3756
  if (state?.mode !== "confirm" || !state.target) return;
3658
- if (state.executing === true || state.busy === true || state.previewLoading === true)
3659
- return;
3757
+ // 运行中的回合不再由客户端拒绝:宿主会先取消并等到 idle 再回溯(v2.1)。
3758
+ if (state.executing === true || state.previewLoading === true) return;
3660
3759
  executeRewind(state.sessionId, state.target, state.rewindMode, state.origin || "row");
3661
3760
  }
3662
3761
 
@@ -4069,6 +4168,7 @@ window.__ModuleLoader__.load({
4069
4168
  pick: pick,
4070
4169
  doRewind: doRewind,
4071
4170
  setRewindMode: setRewindMode,
4171
+ setResend: setResend,
4072
4172
  togglePathList: togglePathList,
4073
4173
  setListSearch: setListSearch,
4074
4174
  loadOlderCandidates: loadOlderCandidates,
package/lib/operations.js CHANGED
@@ -124,12 +124,16 @@ function surfaceNodesOf(session) {
124
124
  *
125
125
  * - not-found:没有 live session(agent 也没有);
126
126
  * - inert:子代理会话(`header.origin === "subagent"` 或 `delegationDepth > 0`);
127
- * - busy:agent 存在且 `status !== "idle"`(v1 不主动打断运行中的回合)。
127
+ * - busy:agent 存在且 `status !== "idle"`,且调用方不允许运行中的会话。
128
+ *
129
+ * 只读操作(候选列表、预览、幽灵历史)传 `allowRunning: true`:它们不改日志,
130
+ * 运行中也能安全求值;真正写日志的 rewind 会先停下回合再重新求值。
128
131
  * @param {ServiceDeps} deps
129
132
  * @param {any} sessionId
133
+ * @param {{allowRunning?: boolean}} [options]
130
134
  * @returns {ResolvedSession}
131
135
  */
132
- export function resolveSession(deps, sessionId) {
136
+ export function resolveSession(deps, sessionId, options = {}) {
133
137
  if (deps.available !== true) {
134
138
  throw new RewindRequestError(
135
139
  ERROR_CODES.failed,
@@ -158,7 +162,12 @@ export function resolveSession(deps, sessionId) {
158
162
  `session "${sessionId}" is a subagent session and cannot be rewound`,
159
163
  );
160
164
  }
161
- if (agent !== undefined && agent !== null && agent.status !== "idle") {
165
+ if (
166
+ options.allowRunning !== true &&
167
+ agent !== undefined &&
168
+ agent !== null &&
169
+ agent.status !== "idle"
170
+ ) {
162
171
  throw new RewindRequestError(
163
172
  ERROR_CODES.busy,
164
173
  `session "${sessionId}" is not idle; stop the running turn before rewinding`,
@@ -218,7 +227,8 @@ function throwPlanError(planError) {
218
227
  * @returns {{sessionId: string, candidates: any[], nextCursor: number | null}}
219
228
  */
220
229
  export function candidatesResponse(deps, request) {
221
- const { session, sessionId } = resolveSession(deps, request?.sessionId);
230
+ // 只读:运行中的会话也能列候选(客户端据此让用户选择「停止并回退」)。
231
+ const { session, sessionId } = resolveSession(deps, request?.sessionId, { allowRunning: true });
222
232
  const page = listCandidatesPage(snapshotEventsOf(session), surfaceNodesOf(session), {
223
233
  limit: request?.limit,
224
234
  cursor: request?.cursor,
@@ -250,6 +260,8 @@ export function candidatesResponse(deps, request) {
250
260
  * @returns {{targetSeq: number, markerSeq: number, removedCount: number, text: string, target: {text: string, images: any[]}}}
251
261
  */
252
262
  export function rewindResponse(deps, request) {
263
+ // 严格模式:这个 helper 代表「append 时会话必须已 idle」。生产路径
264
+ // (SessionRewindService#executeRewind)会先停下运行中的回合再走到这里。
253
265
  const { session, sessionId } = resolveSession(deps, request?.sessionId);
254
266
  const targetSeq = request?.targetSeq;
255
267
  if (!isEventSeq(targetSeq)) {
@@ -336,8 +348,8 @@ export function appendRewindMarker(session, plan, deps, extra = {}) {
336
348
  * @param {any} request `{ sessionId, targetSeq }`
337
349
  * @returns {{session: any, sessionId: string, plan: import("./plan.js").RewindPlan, targetEvent: any}}
338
350
  */
339
- export function resolveRewindTarget(deps, request) {
340
- const { session, sessionId } = resolveSession(deps, request?.sessionId);
351
+ export function resolveRewindTarget(deps, request, options = {}) {
352
+ const { session, sessionId } = resolveSession(deps, request?.sessionId, options);
341
353
  const targetSeq = request?.targetSeq;
342
354
  if (!isEventSeq(targetSeq)) {
343
355
  throw new RewindRequestError(
@@ -358,6 +370,6 @@ export function resolveRewindTarget(deps, request) {
358
370
  * @returns {{session: any, events: any[]}}
359
371
  */
360
372
  export function sessionEvents(deps, sessionId) {
361
- const { session } = resolveSession(deps, sessionId);
373
+ const { session } = resolveSession(deps, sessionId, { allowRunning: true });
362
374
  return { session, events: snapshotEventsOf(session) };
363
375
  }
package/lib/service.js CHANGED
@@ -14,6 +14,7 @@ import { createUserMessage } from "@deepseek-ai/dsh-llm";
14
14
  import { deriveEventMessage, isAppendSurfaceEvent } from "@deepseek-ai/dsh-session/surface";
15
15
  import { estimateMessage } from "@deepseek-ai/dsh-token-meter/estimate";
16
16
  import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
17
+ import { settleAgent } from "./agent-settle.js";
17
18
  import { installFileTracking } from "./file-tracking.js";
18
19
  import { createFileStore } from "./files.js";
19
20
  import { collectGhost } from "./ghost.js";
@@ -23,10 +24,12 @@ import {
23
24
  ERROR_CODES,
24
25
  RewindRequestError,
25
26
  resolveRewindTarget,
27
+ resolveSession,
26
28
  sessionEvents,
27
29
  } from "./operations.js";
28
30
  import { findOrphans, pruneOrphans } from "./orphans.js";
29
31
  import { cutsOf, hasOpenToolCalls, listCandidatesPage, targetContent } from "./plan.js";
32
+ import { describeToolImpact, installRewindTool, listToolCandidates } from "./tool.js";
30
33
 
31
34
  /**
32
35
  * 在 decorator 语法不可用(V8 装饰器关闭)的运行时里登记一个 Remote 方法。
@@ -160,6 +163,18 @@ export class SessionRewindService extends TypertRemoteService {
160
163
  this._store = null;
161
164
  this.installFileTracking();
162
165
  this.installCommand();
166
+ // 只读 agent 工具:让模型自己看清候选与影响面(绝不执行回溯)。
167
+ this.readOnlyToolInstalled = false;
168
+ if (this.config.readOnlyTool !== false) {
169
+ installRewindTool({
170
+ ctx: this.ctx,
171
+ logger: this.ctx?.logger ?? console,
172
+ host: { call: (sessionId, request) => this.toolPayload(sessionId, request) },
173
+ onInstalled: () => {
174
+ this.readOnlyToolInstalled = true;
175
+ },
176
+ });
177
+ }
163
178
  // 启动时清一次已删除会话的残留数据(失败只告警,不阻塞插件)。
164
179
  if (this.config.cleanupOrphans !== false) void this.cleanupOrphans();
165
180
  }
@@ -396,6 +411,58 @@ export class SessionRewindService extends TypertRemoteService {
396
411
  }
397
412
  }
398
413
 
414
+ /**
415
+ * 只读工具的数据面:候选列表 / 单条影响面。绝不写日志。
416
+ * @param {string} sessionId
417
+ * @param {{query?: string, limit?: number, targetSeq?: number}} [request]
418
+ * @returns {Promise<any>}
419
+ */
420
+ async toolPayload(sessionId, request) {
421
+ try {
422
+ const deps = this.deps();
423
+ const { session } = sessionEvents(deps, sessionId);
424
+ const events = session.snapshotEvents();
425
+ const nodes = Array.isArray(session.surface?.nodes) ? session.surface.nodes : [];
426
+ if (typeof request?.targetSeq === "number") {
427
+ const impact = describeToolImpact(events, nodes, request.targetSeq);
428
+ if (impact.ok !== true)
429
+ return { ok: true, value: { kind: "error", session_id: sessionId, error: impact.error } };
430
+ return {
431
+ ok: true,
432
+ value: {
433
+ kind: "impact",
434
+ session_id: sessionId,
435
+ impact: {
436
+ target_seq: impact.targetSeq,
437
+ removed_messages: impact.removedCount,
438
+ crosses_open_tool_call: impact.crossesOpenToolCall,
439
+ text: impact.text,
440
+ },
441
+ },
442
+ };
443
+ }
444
+ const listed = listToolCandidates(events, nodes, {
445
+ ...(typeof request?.query === "string" ? { query: request.query } : {}),
446
+ ...(typeof request?.limit === "number" ? { limit: request.limit } : {}),
447
+ });
448
+ return {
449
+ ok: true,
450
+ value: {
451
+ kind: "list",
452
+ session_id: sessionId,
453
+ total: listed.total,
454
+ candidates: listed.items.map((item) => ({
455
+ seq: item.seq,
456
+ ...(typeof item.time === "number" ? { time: item.time } : {}),
457
+ preview: item.preview,
458
+ })),
459
+ },
460
+ };
461
+ } catch (error) {
462
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
463
+ }
464
+ }
465
+
399
466
  /**
400
467
  * 回溯预览(dry-run):不写任何东西。
401
468
  * @param {{sessionId?: string, targetSeq?: number}} [request]
@@ -403,7 +470,9 @@ export class SessionRewindService extends TypertRemoteService {
403
470
  async preview(request) {
404
471
  try {
405
472
  const deps = this.deps();
406
- const { session, sessionId, plan, targetEvent } = resolveRewindTarget(deps, request);
473
+ const { session, sessionId, plan, targetEvent } = resolveRewindTarget(deps, request, {
474
+ allowRunning: true,
475
+ });
407
476
  const events = session.snapshotEvents();
408
477
  const tokens = this.estimateTokens(events, plan.shadowedSeqs);
409
478
  const target = targetContent(targetEvent);
@@ -504,15 +573,36 @@ export class SessionRewindService extends TypertRemoteService {
504
573
  */
505
574
  async executeRewind(input) {
506
575
  const deps = this.deps();
507
- const resolved = resolveRewindTarget(deps, input);
508
- const { session, sessionId, plan, targetEvent } = resolved;
509
- const agent = deps.getAgent(sessionId);
576
+ // 先按「允许运行中」解析,拿到 agent;运行中的回合必须先停下,否则 surface 会在
577
+ // 我们求值与 append 之间继续增长,marker 的 replace 范围就失效了。
578
+ const pre = resolveSession(deps, input.sessionId, { allowRunning: true });
579
+ const agent = pre.agent;
580
+ /** @type {{cancelledTurn: boolean, droppedSteering: number}} */
581
+ const settle = { cancelledTurn: false, droppedSteering: 0 };
510
582
  if (agent !== undefined && agent !== null && agent.status !== "idle") {
511
- throw new RewindRequestError(
512
- ERROR_CODES.busy,
513
- `session "${sessionId}" is not idle; stop the running turn before rewinding`,
514
- );
583
+ if (this.config.cancelRunning === false) {
584
+ throw new RewindRequestError(
585
+ ERROR_CODES.busy,
586
+ `session "${input.sessionId}" is not idle; stop the running turn before rewinding`,
587
+ );
588
+ }
589
+ const outcome = await settleAgent(agent, {
590
+ ...(typeof this.config.cancelTimeoutMs === "number"
591
+ ? { timeoutMs: this.config.cancelTimeoutMs }
592
+ : {}),
593
+ });
594
+ settle.cancelledTurn = outcome.cancelled;
595
+ settle.droppedSteering = outcome.droppedSteering.length;
596
+ if (!outcome.idle) {
597
+ throw new RewindRequestError(
598
+ ERROR_CODES.busy,
599
+ `session "${input.sessionId}" did not become idle after cancelling the running turn`,
600
+ );
601
+ }
515
602
  }
603
+ // 停稳之后重新求值:取消前的快照可能已经被这一轮追加的事件改变。
604
+ const resolved = resolveRewindTarget(deps, input);
605
+ const { session, sessionId, plan, targetEvent } = resolved;
516
606
  if (this.inflight.has(sessionId)) {
517
607
  throw new RewindRequestError(
518
608
  ERROR_CODES.failed,
@@ -618,6 +708,9 @@ export class SessionRewindService extends TypertRemoteService {
618
708
  auditId,
619
709
  target: targetContent(targetEvent),
620
710
  files,
711
+ // v2.1:执行时是否为了回溯而停下了运行中的回合。
712
+ cancelledTurn: settle.cancelledTurn,
713
+ droppedSteering: settle.droppedSteering,
621
714
  };
622
715
  try {
623
716
  await this.store.appendAudit({
@@ -631,6 +724,8 @@ export class SessionRewindService extends TypertRemoteService {
631
724
  tokens,
632
725
  files,
633
726
  requestedBy: input.requestedBy,
727
+ cancelledTurn: settle.cancelledTurn,
728
+ droppedSteering: settle.droppedSteering,
634
729
  });
635
730
  } catch (error) {
636
731
  this.ctx?.logger?.warn?.(
@@ -807,6 +902,15 @@ export class SessionRewindService extends TypertRemoteService {
807
902
  } catch (error) {
808
903
  push("审计日志", false, error instanceof Error ? error.message : String(error));
809
904
  }
905
+ push(
906
+ "只读工具",
907
+ this.config.readOnlyTool === false ? true : this.readOnlyToolInstalled === true,
908
+ this.config.readOnlyTool === false
909
+ ? "disabled by config"
910
+ : this.readOnlyToolInstalled === true
911
+ ? "enabled"
912
+ : "not registered",
913
+ );
810
914
  try {
811
915
  const orphans = await findOrphans({ dataRoot: this.dataRoot });
812
916
  const count = orphans.sessionIds.length + orphans.trashIds.length + orphans.rescueIds.length;
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
@@ -28,7 +28,7 @@
28
28
  "confirm.pathsLess": "Hide paths",
29
29
  "confirm.skipped": "{n} file(s) skipped",
30
30
  "confirm.conflictsTitle": "Conflicting files (first 5)",
31
- "confirm.busy": "The turn is still running; stop it before rewinding.",
31
+ "confirm.busy": "The turn is still running: rewinding stops it first and drops any queued steering messages.",
32
32
  "confirm.openToolCall": "An unfinished tool call follows the target; rewinding truncates it.",
33
33
  "toast.done": "Rewound. The original message is back in the composer.",
34
34
  "toast.draft": "Rewound. The composer already had content, so it was kept.",
@@ -116,6 +116,12 @@
116
116
  "relative.justNow": "just now",
117
117
  "relative.minutes": "{n} min ago",
118
118
  "relative.hours": "{n} h ago",
119
- "relative.days": "{n} d ago"
119
+ "relative.days": "{n} d ago",
120
+ "dialog.settling": "Stopping the running turn, then rewinding…",
121
+ "confirm.settling": "Stopping the running turn…",
122
+ "toast.stoppedTurn": ", and the running turn was stopped",
123
+ "confirm.resend": "Send this message again right after rewinding",
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"
120
126
  }
121
127
  }
package/locale/zh.json CHANGED
@@ -28,7 +28,7 @@
28
28
  "confirm.pathsLess": "收起路径",
29
29
  "confirm.skipped": "{n} 个文件被跳过",
30
30
  "confirm.conflictsTitle": "冲突文件(前 5 个)",
31
- "confirm.busy": "当前回合仍在运行,请先停止后再回退",
31
+ "confirm.busy": "当前回合仍在运行:回退时会先停止它,队列中未投递的插话会被丢弃",
32
32
  "confirm.openToolCall": "目标之后有未完成的工具调用,回溯会截断它",
33
33
  "toast.done": "已回退到此处,原消息已放回输入框",
34
34
  "toast.draft": "已回退;输入框已有内容,未覆盖",
@@ -116,6 +116,12 @@
116
116
  "relative.justNow": "刚刚",
117
117
  "relative.minutes": "{n} 分钟前",
118
118
  "relative.hours": "{n} 小时前",
119
- "relative.days": "{n} 天前"
119
+ "relative.days": "{n} 天前",
120
+ "dialog.settling": "正在停止当前回合并回退…",
121
+ "confirm.settling": "正在停止当前回合…",
122
+ "toast.stoppedTurn": ",已停止运行中的回合",
123
+ "confirm.resend": "回退后立即重发这条消息",
124
+ "confirm.resendHint": "适合「重试这一轮」:回退后自动把原文发出去,想改内容就在输入框里编辑后再发。",
125
+ "toast.resent": ",已重发这条消息"
120
126
  }
121
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kangyishuai/dsh-session-rewind",
3
- "version": "0.1.1",
3
+ "version": "0.3.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",