dsh-client-auto-continue 0.6.3 → 0.7.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
@@ -43,7 +43,8 @@ For [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh we
43
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)
44
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. The **Resume now** notification button is the one explicit exception: pressing it is the user asking for exactly one send, pause or not
45
45
  - **Notification buttons** — notifications carry **Resume now** (send immediately, ignoring cooldown, the consecutive cap and any pause) and **Pause this session 1h** actions
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
46
+ - **Loop guard** — watches **running** turns too: many consecutive short model messages with no tool call in between (the "Let me read…" spin), or the same tool called repeatedly, trips the guard, which cancels the turn and restarts it with a configurable loop text ("stop repeating, try another way"). The cancel carries an internal marker so it is never confused with a user stop — the restart only happens for guard-initiated cancels. Thresholds and the loop text are configurable
47
+ - **Stats panel** — the settings card shows today's auto-continue count, recoveries, failures, permanent skips, give-ups and loop breaks, broken down by error code, with a one-click reset
47
48
  - **Browser notifications** — optional alerts when auto-continue fires, gives up, or hits a permanent error; the browser asks for permission on first use, and nothing is shown again after a denial
48
49
 
49
50
  It watches the live event streams and reacts to:
@@ -172,6 +173,11 @@ auto-continue:
172
173
  backoffFactor: 2
173
174
  backoffMaxMs: 300000
174
175
  notify: false
176
+ loopGuard: true
177
+ loopShortChars: 40
178
+ loopShortCount: 8
179
+ loopToolRepeat: 4
180
+ loopText: '(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)'
175
181
  ```
176
182
 
177
183
  **How the card works:**
@@ -191,8 +197,13 @@ auto-continue:
191
197
  | Continue text | `继续` | Text automatically sent after an interruption |
192
198
  | Continue text (max tokens) | `继续` | Text sent when the output token ceiling is reached (same placeholders) |
193
199
  | Idempotency guard | `on` | Inspect the last tool call before resuming and steer the model (see What It Does) |
194
- | Guard text (unconfirmed result) | `(上一步工具「{tool}」可能未完成…)` | Appended when the last tool may have partially executed; `{tool}` placeholder |
195
- | Guard text (tool succeeded) | `(上一步工具「{tool}」已完成…)` | Appended when the last tool is confirmed done; `{tool}` / `{result}` placeholders |
200
+ | Loop guard | `on` | Detect a running turn spinning in place and restart it (see What It Does) |
201
+ | Short-sentence max (chars) | `40` | A model message shorter than this counts as a short sentence (spinning signal) |
202
+ | Short-sentence threshold | `8` | Consecutive short sentences with no tool call in between trip the loop guard |
203
+ | Same-tool repeat count | `4` | Consecutive identical tool calls trip the loop guard |
204
+ | Loop text | `(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)` | Text sent after the loop guard restarts a turn; `{tool}` placeholder |
205
+ | Guard text (unconfirmed result) | `(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)` | Appended when the last tool may have partially executed; `{tool}` placeholder |
206
+ | Guard text (tool succeeded) | `(上一步工具「{tool}」已完成, 结果: {result}; 不要重复执行, 直接继续)` | Appended when the last tool is confirmed done; `{tool}` / `{result}` placeholders |
196
207
  | Grace period (ms) | `3000` | Wait after an interruption; cancelled if the host recovers on its own |
197
208
  | Cooldown (ms) | `20000` | Min interval between auto-continues per session (failed attempts count too) |
198
209
  | Max consecutive | `3` | Max consecutive auto-continues; stops until a user intervenes or a turn completes |
@@ -228,7 +239,7 @@ The plugin is browser-only and touches **no files, credentials, or network beyon
228
239
  npm run typecheck # tsc --noEmit
229
240
  npm run build # lib/client.js + lib/index.js + lib/types
230
241
  npm run watch # rebuild on change; host HMR hot-reloads without a page refresh
231
- npm run test # node tests/simulate.mjs — 29 behavioral scenarios
242
+ npm run test # node tests/simulate.mjs — 34 behavioral scenarios
232
243
  ```
233
244
 
234
245
  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
@@ -43,7 +43,8 @@
43
43
  - **幂等护栏** — 续跑前检查上一步工具调用: 结果未确认(回合在工具执行中途夭折, 如 `git push` 可能已经推上去了)时, 续跑消息会提示模型先确认状态、不要重复执行; 工具已确认成功时说明已完成、请勿重复; 工具失败则不加护栏(重试本来就是目的)。两段护栏文本都可配置(支持 `{tool}` / `{result}` 占位符)
44
44
  - **暂停** — 设置卡片里的全局 **暂停自动继续** 开关可立即停掉一切(实时 + 扫描); 会话级暂停(如通过通知按钮)只挂起单个会话, 到期自动恢复。唯一的显式例外是通知里的 **立即续跑** 按钮——按下它等于用户明确要求发送这一次, 不受暂停限制
45
45
  - **通知按钮** — 通知带 **立即续跑**(无视冷却、连续上限与暂停, 马上发送)和 **暂停该会话 1 小时** 按钮
46
- - **统计面板**设置卡片展示今日自动继续次数、恢复成功、继续后失败、永久性跳过、达上限停止, 按错误码分布, 可一键清零
46
+ - **循环守卫**连**运行中的回合**也盯着: 连续多条短句且期间无工具调用(典型的「Let me read…」空转), 或同一工具被连续反复调用, 都会触发守卫: 取消当前回合并用可配置的循环提示文本重启(「停止重复, 换一种方式」)。取消带有内部来源标记, 绝不会与用户手动停止混淆——只有守卫发起的取消才会重启。阈值与提示文本都可配置
47
+ - **统计面板** — 设置卡片展示今日自动继续次数、恢复成功、继续后失败、永久性跳过、达上限停止、循环打断, 按错误码统计, 可一键清零
47
48
  - **浏览器通知** — 可选: 自动继续成功 / 放弃 / 遇到永久性错误时弹出提醒; 首次使用时请求权限, 被拒绝后不再打扰
48
49
 
49
50
  插件监听实时事件流, 对以下情况作出反应:
@@ -172,6 +173,11 @@ auto-continue:
172
173
  backoffFactor: 2
173
174
  backoffMaxMs: 300000
174
175
  notify: false
176
+ loopGuard: true
177
+ loopShortChars: 40
178
+ loopShortCount: 8
179
+ loopToolRepeat: 4
180
+ loopText: '(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)'
175
181
  ```
176
182
 
177
183
  **卡片操作说明:**
@@ -191,8 +197,13 @@ auto-continue:
191
197
  | 继续文本 | `继续` | 中断后自动发送的消息内容 |
192
198
  | 超限时的继续文本 | `继续` | 达到输出 token 上限时自动发送的文本(支持相同占位符) |
193
199
  | 幂等护栏 | 开 | 续跑前检查上一步工具调用并给出指引(见「它做什么」) |
194
- | 结果未确认时的护栏文本 | `(上一步工具「{tool}」可能未完成…)` | 上一步工具可能已部分执行时附加; 支持 {tool} 占位符 |
195
- | 工具已成功时的护栏文本 | `(上一步工具「{tool}」已完成…)` | 上一步工具已确认成功时附加; 支持 {tool} / {result} 占位符 |
200
+ | 循环守卫 | | 检测运行中的回合空转并重启(见「它做什么」) |
201
+ | 短句长度上限 (字符) | `40` | 模型消息文本短于该值计为一条短句(空转信号) |
202
+ | 连续短句阈值 | `8` | 连续多少条短句且期间无工具调用时判定空转循环 |
203
+ | 相同工具连续次数 | `4` | 连续调用同一工具多少次时判定死循环 |
204
+ | 循环提示文本 | `(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)` | 打断后重启回合时发送的文本; 支持 {tool} 占位符 |
205
+ | 结果未确认时的护栏文本 | `(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)` | 上一步工具可能已部分执行时附加; 支持 {tool} 占位符 |
206
+ | 工具已成功时的护栏文本 | `(上一步工具「{tool}」已完成, 结果: {result}; 不要重复执行, 直接继续)` | 上一步工具已确认成功时附加; 支持 {tool} / {result} 占位符 |
196
207
  | 宽限期 (ms) | `3000` | 中断后等待的时长; 期间宿主自行恢复则取消 |
197
208
  | 冷却时间 (ms) | `20000` | 同一会话两次自动「继续」的最小间隔(失败尝试也计入) |
198
209
  | 最大连续次数 | `3` | 同一会话连续自动「继续」上限; 超过后停止, 直到用户介入或成功回合 |
@@ -228,7 +239,7 @@ auto-continue:
228
239
  npm run typecheck # tsc --noEmit
229
240
  npm run build # lib/client.js + lib/index.js + lib/types
230
241
  npm run watch # 监听变更自动重建; 宿主 HMR 免刷新热重载
231
- npm run test # node tests/simulate.mjs — 29 个行为场景
242
+ npm run test # node tests/simulate.mjs — 34 个行为场景
232
243
  ```
233
244
 
234
245
  `npm run watch` 运行时, profile 的 client-hmr 行每 500ms 轮询 `lib/client.js` 并在浏览器中热重载插件——改代码无需重启服务。
package/lib/client.js CHANGED
@@ -58,7 +58,12 @@ var DEFAULT_CONFIG = {
58
58
  backoffFactor: 2,
59
59
  backoffMaxMs: 3e5,
60
60
  notify: false,
61
- paused: false
61
+ paused: false,
62
+ loopGuard: true,
63
+ loopShortChars: 40,
64
+ loopShortCount: 8,
65
+ loopToolRepeat: 4,
66
+ loopText: "(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)"
62
67
  };
63
68
  function numberOr(value, fallback) {
64
69
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
@@ -91,7 +96,12 @@ function resolveConfig(section) {
91
96
  backoffFactor: Math.max(1, numberOr(value.backoffFactor, DEFAULT_CONFIG.backoffFactor)),
92
97
  backoffMaxMs: numberOr(value.backoffMaxMs, DEFAULT_CONFIG.backoffMaxMs),
93
98
  notify: booleanOr(value.notify, DEFAULT_CONFIG.notify),
94
- paused: booleanOr(value.paused, DEFAULT_CONFIG.paused)
99
+ paused: booleanOr(value.paused, DEFAULT_CONFIG.paused),
100
+ loopGuard: booleanOr(value.loopGuard, DEFAULT_CONFIG.loopGuard),
101
+ loopShortChars: Math.max(1, numberOr(value.loopShortChars, DEFAULT_CONFIG.loopShortChars)),
102
+ loopShortCount: Math.max(2, numberOr(value.loopShortCount, DEFAULT_CONFIG.loopShortCount)),
103
+ loopToolRepeat: Math.max(2, numberOr(value.loopToolRepeat, DEFAULT_CONFIG.loopToolRepeat)),
104
+ loopText: typeof value.loopText === "string" && value.loopText.trim() !== "" ? value.loopText : DEFAULT_CONFIG.loopText
95
105
  };
96
106
  }
97
107
  function isNonHumanReason(kind) {
@@ -285,7 +295,7 @@ function bumpStat(delta) {
285
295
  const list = readStats();
286
296
  let day = list.find((item) => item.date === todayKey());
287
297
  if (day === void 0) {
288
- day = { date: todayKey(), sent: 0, skipped: 0, recovered: 0, failed: 0, gaveUp: 0, byCode: {} };
298
+ day = { date: todayKey(), sent: 0, skipped: 0, recovered: 0, failed: 0, gaveUp: 0, looped: 0, byCode: {} };
289
299
  list.unshift(day);
290
300
  }
291
301
  if (delta.sent !== void 0) day.sent += delta.sent;
@@ -293,13 +303,14 @@ function bumpStat(delta) {
293
303
  if (delta.recovered !== void 0) day.recovered += delta.recovered;
294
304
  if (delta.failed !== void 0) day.failed += delta.failed;
295
305
  if (delta.gaveUp !== void 0) day.gaveUp += delta.gaveUp;
306
+ if (delta.looped !== void 0) day.looped += delta.looped;
296
307
  if (delta.code !== void 0) day.byCode[delta.code] = (day.byCode[delta.code] ?? 0) + 1;
297
308
  writeStats(list.slice(0, STATS_MAX_DAYS));
298
309
  }
299
310
  function readTodayStats() {
300
311
  const today = todayKey();
301
312
  const found = readStats().find((item) => item.date === today);
302
- return found ?? { date: today, sent: 0, skipped: 0, recovered: 0, failed: 0, gaveUp: 0, byCode: {} };
313
+ return found ?? { date: today, sent: 0, skipped: 0, recovered: 0, failed: 0, gaveUp: 0, looped: 0, byCode: {} };
303
314
  }
304
315
  function resetTodayStats() {
305
316
  writeStats(readStats().filter((item) => item.date !== todayKey()));
@@ -318,7 +329,11 @@ var freshState = () => ({
318
329
  lastTool: void 0,
319
330
  lastToolResult: void 0,
320
331
  lastTurn: void 0,
321
- pendingRecoveryAt: 0
332
+ pendingRecoveryAt: 0,
333
+ shortRun: 0,
334
+ toolRun: void 0,
335
+ loopFired: false,
336
+ loopCancelled: false
322
337
  });
323
338
  var RECOVERY_WINDOW_MS = 10 * 60 * 1e3;
324
339
  function isOurEcho(state, event) {
@@ -429,12 +444,22 @@ var AutoContinueRunner = class {
429
444
  const state = this.state(frame.sessionId);
430
445
  state.lastTool = name;
431
446
  state.lastToolResult = "pending";
447
+ state.shortRun = 0;
448
+ if (state.toolRun?.name === name) {
449
+ state.toolRun.count += 1;
450
+ } else {
451
+ state.toolRun = { name, count: 1 };
452
+ }
453
+ this.checkLoop(frame.sessionId, state);
432
454
  }
433
455
  } else if (frame.event.type === "tool/result") {
434
456
  const state = this.state(frame.sessionId);
435
457
  if (state.lastToolResult === "pending") {
436
458
  state.lastToolResult = toolResultFacts(frame.event.data);
437
459
  }
460
+ } else if (frame.event.type === "assistant/message") {
461
+ const state = this.state(frame.sessionId);
462
+ this.onAssistantMessage(frame.sessionId, state, frame.event);
438
463
  }
439
464
  this.onSessionEvent(frame.sessionId, frame.event);
440
465
  break;
@@ -449,6 +474,65 @@ var AutoContinueRunner = class {
449
474
  break;
450
475
  }
451
476
  }
477
+ /** 从 assistant/message 事件提取纯文本。 */
478
+ assistantText(event) {
479
+ const content = event.data.message.content;
480
+ if (!Array.isArray(content)) return "";
481
+ return content.filter((part) => part.type === "text").map((part) => part.text).join("");
482
+ }
483
+ /**
484
+ * loop guard 信号 1(空转): 连续短句且期间无工具调用。
485
+ * 短句 = 模型消息文本短于 loopShortChars; 长句或工具调用都会重置计数。
486
+ */
487
+ onAssistantMessage(sessionId, state, event) {
488
+ if (!this.getConfig().loopGuard) return;
489
+ const text = this.assistantText(event);
490
+ if (text.trim().length < this.getConfig().loopShortChars) {
491
+ state.shortRun += 1;
492
+ } else {
493
+ state.shortRun = 0;
494
+ }
495
+ this.checkLoop(sessionId, state);
496
+ }
497
+ /** 两个循环信号的公共检查; 命中且本回合未打断过则打断。 */
498
+ checkLoop(sessionId, state) {
499
+ if (!this.getConfig().loopGuard) return;
500
+ if (state.loopFired) return;
501
+ if (!state.running) return;
502
+ const config = this.getConfig();
503
+ if (state.shortRun >= config.loopShortCount) {
504
+ this.log(`检测到空转循环 ${sessionId}: 连续 ${state.shortRun} 条短句且无工具调用`);
505
+ void this.interruptLoop(sessionId, state);
506
+ } else if (state.toolRun !== void 0 && state.toolRun.count >= config.loopToolRepeat) {
507
+ this.log(`检测到工具死循环 ${sessionId}: 「${state.toolRun.name}」连续 ${state.toolRun.count} 次`);
508
+ void this.interruptLoop(sessionId, state);
509
+ }
510
+ }
511
+ /**
512
+ * 打断运行中的回合: cancel(带来源标记)+ 进冷却。
513
+ * 随后的 turn/end aborted 会因 loopCancelled 走「可恢复中断」路径,
514
+ * 用 loopText 重启回合——不会与用户手动停止混淆。
515
+ */
516
+ async interruptLoop(sessionId, state) {
517
+ if (state.loopFired) return;
518
+ if (Date.now() - state.lastAttemptAt < this.cooldownFor(state)) {
519
+ this.log(`跳过循环打断 ${sessionId}: 处于冷却期`);
520
+ return;
521
+ }
522
+ state.loopFired = true;
523
+ state.loopCancelled = true;
524
+ state.lastAttemptAt = Date.now();
525
+ bumpStat({ looped: 1 });
526
+ try {
527
+ const response = await this.api.sessions.cancel({ sessionId });
528
+ this.log(
529
+ `已打断循环 ${sessionId}: ${response.result.ok ? "cancel 已受理" : "cancel 被拒绝"}`
530
+ );
531
+ } catch (error) {
532
+ this.log(`打断循环失败 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
533
+ state.loopCancelled = false;
534
+ }
535
+ }
452
536
  onSessionEvent(sessionId, event) {
453
537
  const state = this.state(sessionId);
454
538
  switch (event.type) {
@@ -456,6 +540,10 @@ var AutoContinueRunner = class {
456
540
  state.running = true;
457
541
  state.lastTool = void 0;
458
542
  state.lastToolResult = void 0;
543
+ state.shortRun = 0;
544
+ state.toolRun = void 0;
545
+ state.loopFired = false;
546
+ state.loopCancelled = false;
459
547
  this.cancelPending(sessionId, "宿主自行开启新回合");
460
548
  break;
461
549
  case "turn/end": {
@@ -467,8 +555,19 @@ var AutoContinueRunner = class {
467
555
  state.lastFailure = void 0;
468
556
  this.noteRecovery(sessionId, "completed");
469
557
  } else if (reason.kind === "aborted") {
470
- state.consecutive = 0;
471
- state.pendingRecoveryAt = 0;
558
+ if (state.loopCancelled) {
559
+ state.loopCancelled = false;
560
+ state.loopFired = false;
561
+ state.consecutive = 0;
562
+ state.pendingRecoveryAt = 0;
563
+ state.shortRun = 0;
564
+ state.toolRun = void 0;
565
+ state.lastAttemptAt = 0;
566
+ this.schedule(sessionId, "loop:aborted");
567
+ } else {
568
+ state.consecutive = 0;
569
+ state.pendingRecoveryAt = 0;
570
+ }
472
571
  } else if (reason.kind === "blocked") {
473
572
  } else if (reason.kind === "interrupted") {
474
573
  state.consecutive = 0;
@@ -636,7 +735,7 @@ var AutoContinueRunner = class {
636
735
  void this.fire(sessionId, reason);
637
736
  }, config.graceMs);
638
737
  state.pendingTimer = timer;
639
- const template = reason.includes("max-tokens") ? config.continueTextMaxTokens : config.continueText;
738
+ const template = reason.startsWith("loop:") ? config.loopText : reason.includes("max-tokens") ? config.continueTextMaxTokens : config.continueText;
640
739
  this.log(
641
740
  `检测到非人为中断 ${sessionId}(${reason}), ${config.graceMs}ms 后自动发送「${template}」`
642
741
  );
@@ -674,7 +773,7 @@ var AutoContinueRunner = class {
674
773
  this.log(`跳过 ${sessionId}: 其他标签页正在发送`);
675
774
  return;
676
775
  }
677
- const template = reason.includes("max-tokens") ? config.continueTextMaxTokens : config.continueText;
776
+ const template = reason.startsWith("loop:") ? config.loopText : reason.includes("max-tokens") ? config.continueTextMaxTokens : config.continueText;
678
777
  let sessionTitle;
679
778
  if (template.includes("{sessionTitle}")) {
680
779
  sessionTitle = this.titles.get(sessionId);
@@ -946,6 +1045,17 @@ var zh = {
946
1045
  "stats.recovered": "恢复成功",
947
1046
  "stats.failed": "继续后仍失败",
948
1047
  "stats.gaveUp": "停止(达上限)",
1048
+ "stats.looped": "循环打断",
1049
+ "field.loopGuard": "循环守卫",
1050
+ "field.loopGuardHint": "检测运行中的回合空转: 连续短句且无工具调用, 或连续调用相同工具时, 自动取消并用循环提示文本重启回合。",
1051
+ "field.loopShortChars": "短句长度上限 (字符)",
1052
+ "field.loopShortCharsHint": "模型消息文本短于该值计为一条短句(空转信号)。",
1053
+ "field.loopShortCount": "连续短句阈值",
1054
+ "field.loopShortCountHint": "连续多少条短句且期间无工具调用时判定空转循环。",
1055
+ "field.loopToolRepeat": "相同工具连续次数",
1056
+ "field.loopToolRepeatHint": "连续调用同一工具多少次时判定死循环。",
1057
+ "field.loopText": "循环提示文本",
1058
+ "field.loopTextHint": "打断后重启回合时发送的文本, 支持 {tool} 占位符。",
949
1059
  "stats.byCode": "按错误码统计",
950
1060
  "stats.empty": "今天还没有自动继续记录。",
951
1061
  "stats.reset": "清零",
@@ -1016,6 +1126,17 @@ var en = {
1016
1126
  "stats.recovered": "Recovered",
1017
1127
  "stats.failed": "Failed after",
1018
1128
  "stats.gaveUp": "Gave up (cap)",
1129
+ "stats.looped": "Loops broken",
1130
+ "field.loopGuard": "Loop guard",
1131
+ "field.loopGuardHint": "Detects a running turn spinning in place — many short sentences with no tool calls, or the same tool repeating — cancels it and restarts with the loop text.",
1132
+ "field.loopShortChars": "Short-sentence max (chars)",
1133
+ "field.loopShortCharsHint": "A model message shorter than this counts as a short sentence (spinning signal).",
1134
+ "field.loopShortCount": "Short-sentence threshold",
1135
+ "field.loopShortCountHint": "How many consecutive short sentences with no tool call trip the loop guard.",
1136
+ "field.loopToolRepeat": "Same-tool repeat count",
1137
+ "field.loopToolRepeatHint": "How many consecutive identical tool calls trip the loop guard.",
1138
+ "field.loopText": "Loop text",
1139
+ "field.loopTextHint": "Text sent after the loop guard restarts a turn; supports the {tool} placeholder.",
1019
1140
  "stats.byCode": "By error code",
1020
1141
  "stats.empty": "No auto-continue activity today.",
1021
1142
  "stats.reset": "Reset",
@@ -1420,7 +1541,12 @@ var AutoContinueSettingsCardController = class {
1420
1541
  booleanField("classify"),
1421
1542
  numberField("backoffFactor", 1),
1422
1543
  numberField("backoffMaxMs", 0),
1423
- booleanField("notify")
1544
+ booleanField("notify"),
1545
+ booleanField("loopGuard"),
1546
+ numberField("loopShortChars", 1),
1547
+ numberField("loopShortCount", 2),
1548
+ numberField("loopToolRepeat", 2),
1549
+ textField("loopText")
1424
1550
  ]);
1425
1551
  this.store = this.form.bind(() => this.projection(), import_client.createSnapshotStore);
1426
1552
  }
@@ -1445,7 +1571,12 @@ var AutoContinueSettingsCardController = class {
1445
1571
  classify: this.form.field("classify"),
1446
1572
  backoffFactor: this.form.field("backoffFactor"),
1447
1573
  backoffMaxMs: this.form.field("backoffMaxMs"),
1448
- notify: this.form.field("notify")
1574
+ notify: this.form.field("notify"),
1575
+ loopGuard: this.form.field("loopGuard"),
1576
+ loopShortChars: this.form.field("loopShortChars"),
1577
+ loopShortCount: this.form.field("loopShortCount"),
1578
+ loopToolRepeat: this.form.field("loopToolRepeat"),
1579
+ loopText: this.form.field("loopText")
1449
1580
  };
1450
1581
  }
1451
1582
  /**
@@ -1563,7 +1694,7 @@ function LivePanels(props) {
1563
1694
  return () => clearInterval(timer);
1564
1695
  }, []);
1565
1696
  const stats = readTodayStats();
1566
- const hasStats = stats.sent + stats.skipped + stats.recovered + stats.failed + stats.gaveUp > 0;
1697
+ const hasStats = stats.sent + stats.skipped + stats.recovered + stats.failed + stats.gaveUp + stats.looped > 0;
1567
1698
  const codes = Object.entries(stats.byCode).sort((a, b) => b[1] - a[1]).slice(0, 5);
1568
1699
  const paused = pausedSessions();
1569
1700
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
@@ -1604,6 +1735,10 @@ function LivePanels(props) {
1604
1735
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
1605
1736
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("dt", { children: t("stats.gaveUp") }),
1606
1737
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("dd", { children: stats.gaveUp })
1738
+ ] }),
1739
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
1740
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("dt", { children: t("stats.looped") }),
1741
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("dd", { children: stats.looped })
1607
1742
  ] })
1608
1743
  ] }),
1609
1744
  codes.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshAcCodes", children: [
@@ -1697,6 +1832,7 @@ function AutoContinueSettingsCard(props) {
1697
1832
  ...shared,
1698
1833
  ...state.continueText,
1699
1834
  onEdit: (text) => props.edit("continueText", text),
1835
+ placeholder: DEFAULT_CONFIG.continueText,
1700
1836
  onReset: () => props.resetField("continueText")
1701
1837
  }
1702
1838
  ),
@@ -1709,6 +1845,7 @@ function AutoContinueSettingsCard(props) {
1709
1845
  ...shared,
1710
1846
  ...state.continueTextMaxTokens,
1711
1847
  onEdit: (text) => props.edit("continueTextMaxTokens", text),
1848
+ placeholder: DEFAULT_CONFIG.continueTextMaxTokens,
1712
1849
  onReset: () => props.resetField("continueTextMaxTokens")
1713
1850
  }
1714
1851
  ),
@@ -1733,6 +1870,7 @@ function AutoContinueSettingsCard(props) {
1733
1870
  ...shared,
1734
1871
  ...state.guardPendingText,
1735
1872
  onEdit: (text) => props.edit("guardPendingText", text),
1873
+ placeholder: DEFAULT_CONFIG.guardPendingText,
1736
1874
  onReset: () => props.resetField("guardPendingText")
1737
1875
  }
1738
1876
  ),
@@ -1745,6 +1883,7 @@ function AutoContinueSettingsCard(props) {
1745
1883
  ...shared,
1746
1884
  ...state.guardDoneText,
1747
1885
  onEdit: (text) => props.edit("guardDoneText", text),
1886
+ placeholder: DEFAULT_CONFIG.guardDoneText,
1748
1887
  onReset: () => props.resetField("guardDoneText")
1749
1888
  }
1750
1889
  ),
@@ -1913,6 +2052,70 @@ function AutoContinueSettingsCard(props) {
1913
2052
  onReset: () => props.resetField("notify")
1914
2053
  }
1915
2054
  ),
2055
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2056
+ BooleanField,
2057
+ {
2058
+ id: "auto-continue-loop-guard",
2059
+ label: t("field.loopGuard"),
2060
+ hint: t("field.loopGuardHint"),
2061
+ ...shared,
2062
+ ...state.loopGuard,
2063
+ onEdit: (text) => props.edit("loopGuard", text),
2064
+ onReset: () => props.resetField("loopGuard")
2065
+ }
2066
+ ),
2067
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2068
+ ValueField,
2069
+ {
2070
+ id: "auto-continue-loop-short-chars",
2071
+ label: t("field.loopShortChars"),
2072
+ hint: t("field.loopShortCharsHint"),
2073
+ numeric: true,
2074
+ ...shared,
2075
+ ...state.loopShortChars,
2076
+ onEdit: (text) => props.edit("loopShortChars", text),
2077
+ onReset: () => props.resetField("loopShortChars")
2078
+ }
2079
+ ),
2080
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2081
+ ValueField,
2082
+ {
2083
+ id: "auto-continue-loop-short-count",
2084
+ label: t("field.loopShortCount"),
2085
+ hint: t("field.loopShortCountHint"),
2086
+ numeric: true,
2087
+ ...shared,
2088
+ ...state.loopShortCount,
2089
+ onEdit: (text) => props.edit("loopShortCount", text),
2090
+ onReset: () => props.resetField("loopShortCount")
2091
+ }
2092
+ ),
2093
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2094
+ ValueField,
2095
+ {
2096
+ id: "auto-continue-loop-tool-repeat",
2097
+ label: t("field.loopToolRepeat"),
2098
+ hint: t("field.loopToolRepeatHint"),
2099
+ numeric: true,
2100
+ ...shared,
2101
+ ...state.loopToolRepeat,
2102
+ onEdit: (text) => props.edit("loopToolRepeat", text),
2103
+ onReset: () => props.resetField("loopToolRepeat")
2104
+ }
2105
+ ),
2106
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2107
+ ValueField,
2108
+ {
2109
+ id: "auto-continue-loop-text",
2110
+ label: t("field.loopText"),
2111
+ hint: t("field.loopTextHint"),
2112
+ ...shared,
2113
+ ...state.loopText,
2114
+ onEdit: (text) => props.edit("loopText", text),
2115
+ placeholder: DEFAULT_CONFIG.loopText,
2116
+ onReset: () => props.resetField("loopText")
2117
+ }
2118
+ ),
1916
2119
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LivePanels, { t })
1917
2120
  ]
1918
2121
  }