dsh-client-auto-continue 0.5.7 → 0.6.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 CHANGED
@@ -40,6 +40,7 @@ For [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh we
40
40
  - **Error classification** — transient failures (network / timeout / 5xx / 429…) are auto-resumed; permanent ones are **skipped** and notified, because retrying them never helps. A failure counts as permanent when its HTTP status is 401/403 or its code/message matches auth, credential/API-key, balance/quota, unknown-model, or context-length/overflow keywords. Turn classification off to resume everything
41
41
  - **Adaptive backoff** — consecutive failures wait longer each time (cooldown × factor: 20s → 40s → 80s…), capped at the max backoff, instead of hammering a broken upstream
42
42
  - **Templated continue text** — `continueText` supports `{code}` `{message}` `{status}` `{tool}` `{turn}` `{errorCount}` `{sessionTitle}` `{elapsed}` placeholders, so the resume message can carry the failure context ("继续 (git push failed: UPSTREAM)"); a **separate template** fires on `max-tokens` (e.g. "继续输出, 不要重复已生成的内容")
43
+ - **Idempotency guard** — before resuming, the plugin inspects the last tool call: if its result is unconfirmed (the turn died mid-tool, e.g. a `git push` that may have gone through), the resume message tells the model to check state first and not to rerun; if the tool is confirmed done, it says so and asks not to repeat it; a failed tool gets no guard (retrying it is the point). Both guard texts are configurable (`{tool}` / `{result}` placeholders)
43
44
  - **Pause** — a global **Pause auto-continue** toggle in the settings card stops everything (live + scan) instantly; per-session pauses (e.g. via a notification button) suspend only one session until they expire
44
45
  - **Notification buttons** — notifications carry **Resume now** (send immediately, ignoring cooldown and the consecutive cap) and **Pause this session 1h** actions
45
46
  - **Stats panel** — the settings card shows today's auto-continue count, recoveries, failures, permanent skips and give-ups, broken down by error code, with a one-click reset
@@ -124,12 +125,12 @@ dsh web
124
125
 
125
126
  > Switching from a manual install to `dsh plugin add`? Remove the manual `insert` entry first — the bundle patch registers the row and a duplicate would conflict.
126
127
 
127
- > **Known DSH limitation (0.1.0-rc.6):** the web settings surface only exposes
128
- > namespaces on a hardcoded allowlist in the installed `@deepseek-ai/dsh-host-apiproxy`
129
- > bundle. Until upstream moves exposure into `settings.register()`, one idempotent
130
- > vendor patch makes exposure **registry-driven** every namespace a plugin
131
- > registers (this one or any other) becomes visible, with no plugin-specific
132
- > strings anywhere. Run it once, and re-run after reinstalling dsh:
128
+ > **Note (DSH 0.1.0-rc.7):** upstream made settings exposure **registry-driven**
129
+ > every namespace a plugin registers is served to the web settings surface,
130
+ > so the settings card just works, no vendor patch needed. Only DSH 0.1.0-rc.6
131
+ > and earlier gate exposure behind a hardcoded allowlist in
132
+ > `@deepseek-ai/dsh-host-apiproxy`; on those versions, run the idempotent
133
+ > vendor patch once (and re-run after reinstalling dsh):
133
134
  >
134
135
  > ```sh
135
136
  > npx --yes --package dsh-client-auto-continue patch-expose
@@ -143,7 +144,7 @@ dsh web
143
144
  > own. If you installed from this repository instead, the same script is
144
145
  > `node scripts/patch-expose.mjs` at the repo root. The auto-continue engine
145
146
  > itself works without this patch — it only gates the GUI settings section;
146
- > until then, configure everything directly in `~/.dsh/settings.yaml`
147
+ > on rc.6 and earlier, configure everything directly in `~/.dsh/settings.yaml`
147
148
  > (see [Configuration](#configuration)).
148
149
 
149
150
  ### Verify & uninstall
@@ -173,6 +174,9 @@ auto-continue:
173
174
  paused: false
174
175
  continueText: '继续'
175
176
  continueTextMaxTokens: '继续'
177
+ guardTools: true
178
+ guardPendingText: '(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)'
179
+ guardDoneText: '(上一步工具「{tool}」已完成, 结果: {result}; 不要重复执行, 直接继续)'
176
180
  graceMs: 3000
177
181
  cooldownMs: 20000
178
182
  maxConsecutive: 3
@@ -204,6 +208,9 @@ auto-continue:
204
208
  | Pause auto-continue | `off` | Global pause: no live or scan auto-send fires, queued pending sends are cancelled |
205
209
  | Continue text | `继续` | Text automatically sent after an interruption |
206
210
  | Continue text (max tokens) | `继续` | Text sent when the output token ceiling is reached (same placeholders) |
211
+ | Idempotency guard | `on` | Inspect the last tool call before resuming and steer the model (see What It Does) |
212
+ | Guard text (unconfirmed result) | `(上一步工具「{tool}」可能未完成…)` | Appended when the last tool may have partially executed; `{tool}` placeholder |
213
+ | Guard text (tool succeeded) | `(上一步工具「{tool}」已完成…)` | Appended when the last tool is confirmed done; `{tool}` / `{result}` placeholders |
207
214
  | Grace period (ms) | `3000` | Wait after an interruption; cancelled if the host recovers on its own |
208
215
  | Cooldown (ms) | `20000` | Min interval between auto-continues per session (failed attempts count too) |
209
216
  | Max consecutive | `3` | Max consecutive auto-continues; stops until a user intervenes or a turn completes |
@@ -218,7 +225,7 @@ auto-continue:
218
225
  | Max backoff (ms) | `300000` | Cap on the adaptive backoff interval |
219
226
  | Browser notifications | `off` | Notify when auto-continue fires, gives up, or hits a permanent error |
220
227
 
221
- `continueText` (and `continueTextMaxTokens`) accept the placeholders `{code}`, `{message}`, `{status}`, `{tool}` (last tool call before the failure), `{turn}`, `{errorCount}` (consecutive failures including this one), `{sessionTitle}` (from the session list) and `{elapsed}` (time since the failure, e.g. `1m5s`) — e.g. `继续 ({tool}: {code})` becomes `继续 (git push: UPSTREAM)`.
228
+ `continueText` (and `continueTextMaxTokens`) accept the placeholders `{code}`, `{message}`, `{status}`, `{tool}` (last tool call before the failure), `{turn}`, `{errorCount}` (consecutive failures including this one), `{sessionTitle}` (from the session list) and `{elapsed}` (time since the failure, e.g. `1m5s`) — e.g. `继续 ({tool}: {code})` becomes `继续 (git push: UPSTREAM)`. The guard texts accept `{tool}` and `{result}` (a truncated excerpt of the last tool output).
222
229
 
223
230
  ---
224
231
 
@@ -239,7 +246,7 @@ The plugin is browser-only and touches **no files, credentials, or network beyon
239
246
  npm run typecheck # tsc --noEmit
240
247
  npm run build # lib/client.js + lib/index.js + lib/types
241
248
  npm run watch # rebuild on change; host HMR hot-reloads without a page refresh
242
- npm run test # node tests/simulate.mjs — 22 behavioral scenarios
249
+ npm run test # node tests/simulate.mjs — 27 behavioral scenarios
243
250
  ```
244
251
 
245
252
  While `npm run watch` runs, the profile's client-hmr row polls `lib/client.js` every 500 ms and hot-reloads the plugin in the browser — no server restart needed for code changes.
package/README.zh.md CHANGED
@@ -40,6 +40,7 @@
40
40
  - **错误分类** — 临时性错误(网络 / 超时 / 5xx / 429 等)自动续跑; 永久性错误跳过并通知, 因为重试也没用。判定为永久性的条件: HTTP 状态码 401/403, 或 code/message 命中认证、凭据/API Key、余额/配额、模型不存在、上下文长度/超限等关键词。关闭分类后则全部自动继续
41
41
  - **自适应退避** — 连续失败时等待时间递增(冷却 × 系数: 20s → 40s → 80s…), 有上限, 不再对故障上游狂轰滥炸
42
42
  - **模板化继续文本** — `continueText` 支持 `{code}` `{message}` `{status}` `{tool}` `{turn}` `{errorCount}` `{sessionTitle}` `{elapsed}` 占位符, 续跑消息可携带失败上下文(如「继续 (git push 失败: UPSTREAM)」); 达到 `max-tokens` 时使用**另一套模板**(如「继续输出, 不要重复已生成的内容」)
43
+ - **幂等护栏** — 续跑前检查上一步工具调用: 结果未确认(回合在工具执行中途夭折, 如 `git push` 可能已经推上去了)时, 续跑消息会提示模型先确认状态、不要重复执行; 工具已确认成功时说明已完成、请勿重复; 工具失败则不加护栏(重试本来就是目的)。两段护栏文本都可配置(支持 `{tool}` / `{result}` 占位符)
43
44
  - **暂停** — 设置卡片里的全局 **暂停自动继续** 开关可立即停掉一切(实时 + 扫描); 会话级暂停(如通过通知按钮)只挂起单个会话, 到期自动恢复
44
45
  - **通知按钮** — 通知带 **立即续跑**(无视冷却与连续上限, 马上发送)和 **暂停该会话 1 小时** 按钮
45
46
  - **统计面板** — 设置卡片展示今日自动继续次数、恢复成功、继续后失败、永久性跳过、达上限停止, 按错误码分布, 可一键清零
@@ -124,11 +125,10 @@ dsh web
124
125
 
125
126
  > 从手动安装切换到 `dsh plugin add` 时, 请先删掉手动加的 `insert` 条目——包自带的 bundle patch 会注册插件行, 重复注册会冲突。
126
127
 
127
- > **已知 DSH 限制(0.1.0-rc.6):** webui 的设置区只暴露已安装
128
- > `@deepseek-ai/dsh-host-apiproxy` 包中硬编码白名单里的设置命名空间。在官方把暴露
129
- > 逻辑移入 `settings.register()` 之前, 需要执行一次幂等的供应商补丁, 让暴露变成
130
- > **注册表驱动**——任何插件注册的命名空间(本插件或其他插件)都会自动可见, 补丁中
131
- > 没有任何插件相关的硬编码字符串。执行一次即可, 重新安装 dsh 后重跑:
128
+ > **说明(DSH 0.1.0-rc.7):** 上游已把设置暴露改成**注册表驱动**——插件注册的
129
+ > 任何命名空间都会直接出现在 webui 设置区, 设置卡片开箱即用, 无需任何供应商补丁。
130
+ > DSH 0.1.0-rc.6 及更早版本仍在 `@deepseek-ai/dsh-host-apiproxy` 里用硬编码白名单
131
+ > 门禁暴露; 这些版本需要执行一次幂等的供应商补丁(重新安装 dsh 后重跑):
132
132
  >
133
133
  > ```sh
134
134
  > npx --yes --package dsh-client-auto-continue patch-expose
@@ -139,8 +139,8 @@ dsh web
139
139
  > 无需猜测 profile 路径), 并覆盖所有可达的 dsh 安装: profile 链接副本、全局
140
140
  > `npm i -g @deepseek-ai/dsh` 安装、以及当前目录的安装。若是从本仓库安装,
141
141
  > 同一脚本在仓库根目录执行 `node scripts/patch-expose.mjs` 即可。自动续跑引擎
142
- > 本身不依赖这个补丁——它只影响 GUI 设置区是否可见; 在此之前, 可以直接在
143
- > `~/.dsh/settings.yaml` 里配置全部参数(见 [配置](#配置))。
142
+ > 本身不依赖这个补丁——它只影响 GUI 设置区是否可见; rc.6 及更早版本上,
143
+ > 可以直接在 `~/.dsh/settings.yaml` 里配置全部参数(见 [配置](#配置))。
144
144
 
145
145
  ### 验证与卸载
146
146
 
@@ -169,6 +169,9 @@ auto-continue:
169
169
  paused: false
170
170
  continueText: '继续'
171
171
  continueTextMaxTokens: '继续'
172
+ guardTools: true
173
+ guardPendingText: '(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)'
174
+ guardDoneText: '(上一步工具「{tool}」已完成, 结果: {result}; 不要重复执行, 直接继续)'
172
175
  graceMs: 3000
173
176
  cooldownMs: 20000
174
177
  maxConsecutive: 3
@@ -200,6 +203,9 @@ auto-continue:
200
203
  | 暂停自动继续 | 关 | 全局暂停: 实时与扫描都不再自动发送, 已排队的待发送也会取消 |
201
204
  | 继续文本 | `继续` | 中断后自动发送的消息内容 |
202
205
  | 超限时的继续文本 | `继续` | 达到输出 token 上限时自动发送的文本(支持相同占位符) |
206
+ | 幂等护栏 | 开 | 续跑前检查上一步工具调用并给出指引(见「它做什么」) |
207
+ | 结果未确认时的护栏文本 | `(上一步工具「{tool}」可能未完成…)` | 上一步工具可能已部分执行时附加; 支持 {tool} 占位符 |
208
+ | 工具已成功时的护栏文本 | `(上一步工具「{tool}」已完成…)` | 上一步工具已确认成功时附加; 支持 {tool} / {result} 占位符 |
203
209
  | 宽限期 (ms) | `3000` | 中断后等待的时长; 期间宿主自行恢复则取消 |
204
210
  | 冷却时间 (ms) | `20000` | 同一会话两次自动「继续」的最小间隔(失败尝试也计入) |
205
211
  | 最大连续次数 | `3` | 同一会话连续自动「继续」上限; 超过后停止, 直到用户介入或成功回合 |
@@ -214,7 +220,7 @@ auto-continue:
214
220
  | 最大退避间隔 (ms) | `300000` | 自适应退避的上限 |
215
221
  | 浏览器通知 | 关 | 自动继续成功 / 放弃 / 遇到永久性错误时弹通知 |
216
222
 
217
- `continueText`(以及 `continueTextMaxTokens`)支持占位符 `{code}`、`{message}`、`{status}`、`{tool}`(失败前最后一次工具调用)、`{turn}`、`{errorCount}`(连续失败次数, 含本次)、`{sessionTitle}`(来自会话列表)和 `{elapsed}`(距失败经过的时间, 如 `1m5s`)——例如 `继续 ({tool}: {code})` 会变成 `继续 (git push: UPSTREAM)`。
223
+ `continueText`(以及 `continueTextMaxTokens`)支持占位符 `{code}`、`{message}`、`{status}`、`{tool}`(失败前最后一次工具调用)、`{turn}`、`{errorCount}`(连续失败次数, 含本次)、`{sessionTitle}`(来自会话列表)和 `{elapsed}`(距失败经过的时间, 如 `1m5s`)——例如 `继续 ({tool}: {code})` 会变成 `继续 (git push: UPSTREAM)`。护栏文本支持 `{tool}` 与 `{result}`(上一步工具输出的截断摘要)。
218
224
 
219
225
  ---
220
226
 
@@ -235,7 +241,7 @@ auto-continue:
235
241
  npm run typecheck # tsc --noEmit
236
242
  npm run build # lib/client.js + lib/index.js + lib/types
237
243
  npm run watch # 监听变更自动重建; 宿主 HMR 免刷新热重载
238
- npm run test # node tests/simulate.mjs — 22 个行为场景
244
+ npm run test # node tests/simulate.mjs — 27 个行为场景
239
245
  ```
240
246
 
241
247
  `npm run watch` 运行时, profile 的 client-hmr 行每 500ms 轮询 `lib/client.js` 并在浏览器中热重载插件——改代码无需重启服务。
package/lib/client.js CHANGED
@@ -42,6 +42,9 @@ module.exports = __toCommonJS(index_exports);
42
42
  var DEFAULT_CONFIG = {
43
43
  continueText: "继续",
44
44
  continueTextMaxTokens: "继续",
45
+ guardTools: true,
46
+ guardPendingText: "(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)",
47
+ guardDoneText: "(上一步工具「{tool}」已完成, 结果: {result}; 不要重复执行, 直接继续)",
45
48
  graceMs: 3e3,
46
49
  cooldownMs: 2e4,
47
50
  maxConsecutive: 3,
@@ -67,9 +70,14 @@ function resolveConfig(section) {
67
70
  const value = section ?? {};
68
71
  const text = typeof value.continueText === "string" && value.continueText.trim() !== "" ? value.continueText : DEFAULT_CONFIG.continueText;
69
72
  const maxTokensText = typeof value.continueTextMaxTokens === "string" && value.continueTextMaxTokens.trim() !== "" ? value.continueTextMaxTokens : DEFAULT_CONFIG.continueTextMaxTokens;
73
+ const guardPendingText = typeof value.guardPendingText === "string" && value.guardPendingText.trim() !== "" ? value.guardPendingText : DEFAULT_CONFIG.guardPendingText;
74
+ const guardDoneText = typeof value.guardDoneText === "string" && value.guardDoneText.trim() !== "" ? value.guardDoneText : DEFAULT_CONFIG.guardDoneText;
70
75
  return {
71
76
  continueText: text,
72
77
  continueTextMaxTokens: maxTokensText,
78
+ guardTools: booleanOr(value.guardTools, DEFAULT_CONFIG.guardTools),
79
+ guardPendingText,
80
+ guardDoneText,
73
81
  graceMs: numberOr(value.graceMs, DEFAULT_CONFIG.graceMs),
74
82
  cooldownMs: numberOr(value.cooldownMs, DEFAULT_CONFIG.cooldownMs),
75
83
  maxConsecutive: Math.max(1, numberOr(value.maxConsecutive, DEFAULT_CONFIG.maxConsecutive)),
@@ -139,7 +147,31 @@ function formatElapsed(ms) {
139
147
  return `${Math.floor(s / 60)}m${s % 60 > 0 ? `${s % 60}s` : ""}`;
140
148
  }
141
149
  function fillTemplate(template, ctx) {
142
- return template.replace(/\{code\}/g, ctx.facts?.code ?? "").replace(/\{message\}/g, ctx.facts?.message ?? "").replace(/\{status\}/g, ctx.facts?.status !== void 0 ? String(ctx.facts.status) : "").replace(/\{tool\}/g, ctx.tool ?? "").replace(/\{turn\}/g, ctx.turn !== void 0 ? String(ctx.turn) : "").replace(/\{errorCount\}/g, ctx.errorCount !== void 0 ? String(ctx.errorCount) : "").replace(/\{sessionTitle\}/g, ctx.sessionTitle ?? "").replace(/\{elapsed\}/g, formatElapsed(ctx.elapsedMs));
150
+ return template.replace(/\{code\}/g, ctx.facts?.code ?? "").replace(/\{message\}/g, ctx.facts?.message ?? "").replace(/\{status\}/g, ctx.facts?.status !== void 0 ? String(ctx.facts.status) : "").replace(/\{tool\}/g, ctx.tool ?? "").replace(/\{turn\}/g, ctx.turn !== void 0 ? String(ctx.turn) : "").replace(/\{errorCount\}/g, ctx.errorCount !== void 0 ? String(ctx.errorCount) : "").replace(/\{sessionTitle\}/g, ctx.sessionTitle ?? "").replace(/\{elapsed\}/g, formatElapsed(ctx.elapsedMs)).replace(/\{result\}/g, ctx.result ?? "");
151
+ }
152
+ var TOOL_RESULT_CAP = 160;
153
+ function extractText(blocks, cap) {
154
+ let out = "";
155
+ const walk = (value) => {
156
+ if (out.length >= cap) return;
157
+ if (Array.isArray(value)) {
158
+ for (const item of value) walk(item);
159
+ return;
160
+ }
161
+ if (typeof value !== "object" || value === null) return;
162
+ const record = value;
163
+ if (record["type"] === "text" && typeof record["text"] === "string") {
164
+ out += record["text"];
165
+ return;
166
+ }
167
+ for (const child of Object.values(record)) walk(child);
168
+ };
169
+ walk(blocks);
170
+ return out.slice(0, cap);
171
+ }
172
+ function toolResultFacts(data) {
173
+ const failed = data.error !== void 0 || data.message?.content?.[0]?.isError === true;
174
+ return { ok: !failed, excerpt: extractText(data.message?.content?.[0]?.content, TOOL_RESULT_CAP) };
143
175
  }
144
176
  function effectiveCooldown(consecutive, base, factor, max) {
145
177
  const multiplier = Math.pow(factor, consecutive);
@@ -284,6 +316,7 @@ var freshState = () => ({
284
316
  lastFailure: void 0,
285
317
  lastFailureAt: 0,
286
318
  lastTool: void 0,
319
+ lastToolResult: void 0,
287
320
  lastTurn: void 0,
288
321
  pendingRecoveryAt: 0
289
322
  });
@@ -392,7 +425,16 @@ var AutoContinueRunner = class {
392
425
  case "session/event":
393
426
  if (frame.event.type === "tool/call") {
394
427
  const name = frame.event.data.name;
395
- if (typeof name === "string") this.state(frame.sessionId).lastTool = name;
428
+ if (typeof name === "string") {
429
+ const state = this.state(frame.sessionId);
430
+ state.lastTool = name;
431
+ state.lastToolResult = "pending";
432
+ }
433
+ } else if (frame.event.type === "tool/result") {
434
+ const state = this.state(frame.sessionId);
435
+ if (state.lastToolResult === "pending") {
436
+ state.lastToolResult = toolResultFacts(frame.event.data);
437
+ }
396
438
  }
397
439
  this.onSessionEvent(frame.sessionId, frame.event);
398
440
  break;
@@ -412,6 +454,8 @@ var AutoContinueRunner = class {
412
454
  switch (event.type) {
413
455
  case "turn/start":
414
456
  state.running = true;
457
+ state.lastTool = void 0;
458
+ state.lastToolResult = void 0;
415
459
  this.cancelPending(sessionId, "宿主自行开启新回合");
416
460
  break;
417
461
  case "turn/end": {
@@ -639,14 +683,7 @@ var AutoContinueRunner = class {
639
683
  sessionTitle = info?.title;
640
684
  }
641
685
  }
642
- const text = fillTemplate(template, {
643
- facts: state.lastFailure,
644
- tool: state.lastTool,
645
- turn: state.lastTurn,
646
- errorCount: state.consecutive + 1,
647
- sessionTitle,
648
- elapsedMs: state.lastFailureAt > 0 ? Date.now() - state.lastFailureAt : void 0
649
- });
686
+ const text = this.buildContinueText(config, state, template, sessionTitle);
650
687
  const zone = clientTimeZone();
651
688
  state.lastAttemptAt = Date.now();
652
689
  try {
@@ -694,6 +731,40 @@ var AutoContinueRunner = class {
694
731
  releaseSend(sessionId);
695
732
  }
696
733
  }
734
+ /**
735
+ * 组装本次续跑消息: 模板填充 + 幂等护栏。
736
+ * 护栏依据上一步工具调用的执行状态附加指引, 防止重跑副作用操作:
737
+ * - 结果未确认(可能已部分执行)→ 提示先确认状态、不要重复执行
738
+ * - 已确认成功 → 提示已完成、不要重复执行
739
+ * - 已失败 → 不加护栏(重试工具本来就是目的)
740
+ */
741
+ buildContinueText(config, state, template, sessionTitle) {
742
+ let text = fillTemplate(template, {
743
+ facts: state.lastFailure,
744
+ tool: state.lastTool,
745
+ turn: state.lastTurn,
746
+ errorCount: state.consecutive + 1,
747
+ sessionTitle,
748
+ elapsedMs: state.lastFailureAt > 0 ? Date.now() - state.lastFailureAt : void 0
749
+ });
750
+ if (!config.guardTools) return text;
751
+ const guard = this.currentGuard(state);
752
+ if (guard.kind === "pending") {
753
+ text += ` ${fillTemplate(config.guardPendingText, { tool: guard.tool, result: guard.result })}`;
754
+ } else if (guard.kind === "done") {
755
+ text += ` ${fillTemplate(config.guardDoneText, { tool: guard.tool, result: guard.result })}`;
756
+ }
757
+ return text;
758
+ }
759
+ /** 上一步工具调用的护栏状态(实时路径, 由 mux 帧维护)。 */
760
+ currentGuard(state) {
761
+ if (state.lastTool === void 0 || state.lastToolResult === void 0) return { kind: "none" };
762
+ if (state.lastToolResult === "pending") return { kind: "pending", tool: state.lastTool };
763
+ if (state.lastToolResult.ok) {
764
+ return { kind: "done", tool: state.lastTool, result: state.lastToolResult.excerpt };
765
+ }
766
+ return { kind: "failed", tool: state.lastTool };
767
+ }
697
768
  /** 查一次 session.list, 顺带缓存该会话的标题。 */
698
769
  async fetchSessionInfo(sessionId) {
699
770
  try {
@@ -797,11 +868,34 @@ var AutoContinueRunner = class {
797
868
  if (superseded) break;
798
869
  }
799
870
  if (superseded) continue;
871
+ this.applyGuardFromEvents(state, events, lastEnd.seq);
800
872
  this.log(`扫描发现中断 ${summary.sessionId}(turn/end:${reason.kind}), 安排自动继续`);
801
873
  this.schedule(summary.sessionId, `scan:turn/end:${reason.kind}`);
802
874
  }
803
875
  return true;
804
876
  }
877
+ /** 从历史事件恢复上一步工具调用状态(扫描路径的幂等护栏)。 */
878
+ applyGuardFromEvents(state, events, untilSeq) {
879
+ state.lastTool = void 0;
880
+ state.lastToolResult = void 0;
881
+ let call;
882
+ for (const entry of events) {
883
+ const event = entry.event;
884
+ if (event.seq >= untilSeq) continue;
885
+ if (event.type === "tool/call") call = event;
886
+ }
887
+ if (call === void 0) return;
888
+ state.lastTool = call.data.name;
889
+ state.lastToolResult = "pending";
890
+ for (const entry of events) {
891
+ const event = entry.event;
892
+ if (event.seq <= call.seq || event.seq >= untilSeq) continue;
893
+ if (event.type === "tool/result") {
894
+ state.lastToolResult = toolResultFacts(event.data);
895
+ break;
896
+ }
897
+ }
898
+ }
805
899
  };
806
900
 
807
901
  // src/client/locales.ts
@@ -814,6 +908,12 @@ var zh = {
814
908
  "field.continueTextHint": "中断后自动发送的消息内容。",
815
909
  "field.continueTextMaxTokens": "超限时的继续文本",
816
910
  "field.continueTextMaxTokensHint": "达到输出 token 上限时自动发送的文本, 支持与继续文本相同的占位符。",
911
+ "field.guardTools": "幂等护栏",
912
+ "field.guardToolsHint": "续跑前检查上一步工具调用: 结果未确认时提示先确认状态, 已成功时提示不要重复执行, 避免重复 commit/调 API。",
913
+ "field.guardPendingText": "结果未确认时的护栏文本",
914
+ "field.guardPendingTextHint": "上一步工具可能已部分执行时附加到继续文本之后, 支持 {tool} 占位符。",
915
+ "field.guardDoneText": "工具已成功时的护栏文本",
916
+ "field.guardDoneTextHint": "上一步工具已确认成功时附加到继续文本之后, 支持 {tool} 与 {result}(结果摘要)占位符。",
817
917
  "field.graceMs": "宽限期 (ms)",
818
918
  "field.graceMsHint": "检测到中断后等待的时长; 期间宿主自行恢复则取消。",
819
919
  "field.cooldownMs": "冷却时间 (ms)",
@@ -878,6 +978,12 @@ var en = {
878
978
  "field.continueTextHint": "Message automatically sent after an interruption.",
879
979
  "field.continueTextMaxTokens": "Continue text (max tokens)",
880
980
  "field.continueTextMaxTokensHint": "Text sent when the output token ceiling is reached; same placeholders as the continue text.",
981
+ "field.guardTools": "Idempotency guard",
982
+ "field.guardToolsHint": "Before resuming, inspect the last tool call: if its result is unconfirmed, tell the model to check state first; if it succeeded, tell it not to rerun — avoids duplicate commits / API calls.",
983
+ "field.guardPendingText": "Guard text (unconfirmed result)",
984
+ "field.guardPendingTextHint": "Appended when the last tool may have partially executed; supports the {tool} placeholder.",
985
+ "field.guardDoneText": "Guard text (tool succeeded)",
986
+ "field.guardDoneTextHint": "Appended when the last tool is confirmed done; supports {tool} and {result} (result excerpt).",
881
987
  "field.graceMs": "Grace period (ms)",
882
988
  "field.graceMsHint": "Wait after an interruption; cancelled if the host recovers on its own.",
883
989
  "field.cooldownMs": "Cooldown (ms)",
@@ -1299,6 +1405,9 @@ var AutoContinueSettingsCardController = class {
1299
1405
  booleanField("paused"),
1300
1406
  textField("continueText"),
1301
1407
  textField("continueTextMaxTokens"),
1408
+ booleanField("guardTools"),
1409
+ textField("guardPendingText"),
1410
+ textField("guardDoneText"),
1302
1411
  numberField("graceMs", 0),
1303
1412
  numberField("cooldownMs", 0),
1304
1413
  numberField("maxConsecutive", 1),
@@ -1321,6 +1430,9 @@ var AutoContinueSettingsCardController = class {
1321
1430
  paused: this.form.field("paused"),
1322
1431
  continueText: this.form.field("continueText"),
1323
1432
  continueTextMaxTokens: this.form.field("continueTextMaxTokens"),
1433
+ guardTools: this.form.field("guardTools"),
1434
+ guardPendingText: this.form.field("guardPendingText"),
1435
+ guardDoneText: this.form.field("guardDoneText"),
1324
1436
  graceMs: this.form.field("graceMs"),
1325
1437
  cooldownMs: this.form.field("cooldownMs"),
1326
1438
  maxConsecutive: this.form.field("maxConsecutive"),
@@ -1600,6 +1712,42 @@ function AutoContinueSettingsCard(props) {
1600
1712
  onReset: () => props.resetField("continueTextMaxTokens")
1601
1713
  }
1602
1714
  ),
1715
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1716
+ BooleanField,
1717
+ {
1718
+ id: "auto-continue-guard-tools",
1719
+ label: t("field.guardTools"),
1720
+ hint: t("field.guardToolsHint"),
1721
+ ...shared,
1722
+ ...state.guardTools,
1723
+ onEdit: (text) => props.edit("guardTools", text),
1724
+ onReset: () => props.resetField("guardTools")
1725
+ }
1726
+ ),
1727
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1728
+ ValueField,
1729
+ {
1730
+ id: "auto-continue-guard-pending-text",
1731
+ label: t("field.guardPendingText"),
1732
+ hint: t("field.guardPendingTextHint"),
1733
+ ...shared,
1734
+ ...state.guardPendingText,
1735
+ onEdit: (text) => props.edit("guardPendingText", text),
1736
+ onReset: () => props.resetField("guardPendingText")
1737
+ }
1738
+ ),
1739
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1740
+ ValueField,
1741
+ {
1742
+ id: "auto-continue-guard-done-text",
1743
+ label: t("field.guardDoneText"),
1744
+ hint: t("field.guardDoneTextHint"),
1745
+ ...shared,
1746
+ ...state.guardDoneText,
1747
+ onEdit: (text) => props.edit("guardDoneText", text),
1748
+ onReset: () => props.resetField("guardDoneText")
1749
+ }
1750
+ ),
1603
1751
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1604
1752
  ValueField,
1605
1753
  {
@@ -1787,8 +1935,7 @@ function apply(ctx) {
1787
1935
  () => ctx.slots.register(
1788
1936
  {
1789
1937
  name: "settings.plugin.item",
1790
- id: "auto-continue",
1791
- order: 90,
1938
+ key: SETTINGS_NS,
1792
1939
  locale: NS,
1793
1940
  inject: () => controller.inject()
1794
1941
  },