dsh-client-auto-continue 0.7.2 → 0.7.4

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/lib/index.js CHANGED
@@ -49,6 +49,8 @@ var AutoContinueSchema = z.object({
49
49
  loopWindowMs: z.natural().min(1e3).default(3e4),
50
50
  /** Consecutive short sentences trip the loop guard. */
51
51
  loopShortCount: z.natural().min(2).default(12),
52
+ /** Consecutive identical short sentences trip the loop guard (strongest spinning signal). */
53
+ loopRepeatText: z.natural().min(2).default(4),
52
54
  /** Consecutive identical tool calls with identical arguments AND results trip the loop guard. */
53
55
  loopToolRepeat: z.natural().min(2).default(5),
54
56
  /** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
@@ -61,6 +61,8 @@ export interface AutoContinueSettings {
61
61
  loopShortCount?: number;
62
62
  /** Consecutive identical tool calls with identical arguments AND identical results trip the loop guard. */
63
63
  loopToolRepeat?: number;
64
+ /** Consecutive identical short sentences trip the loop guard (strongest spinning signal). */
65
+ loopRepeatText?: number;
64
66
  /** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
65
67
  loopText?: string;
66
68
  }
@@ -60,6 +60,8 @@ export declare const zh: {
60
60
  'field.loopWindowMsHint': string;
61
61
  'field.loopShortCount': string;
62
62
  'field.loopShortCountHint': string;
63
+ 'field.loopRepeatText': string;
64
+ 'field.loopRepeatTextHint': string;
63
65
  'field.loopToolRepeat': string;
64
66
  'field.loopToolRepeatHint': string;
65
67
  'field.loopText': string;
@@ -27,6 +27,7 @@ export interface AutoContinueSettingsCardState extends CardShell {
27
27
  loopShortChars: CardFieldState;
28
28
  loopWindowMs: CardFieldState;
29
29
  loopShortCount: CardFieldState;
30
+ loopRepeatText: CardFieldState;
30
31
  loopToolRepeat: CardFieldState;
31
32
  loopText: CardFieldState;
32
33
  }
@@ -55,6 +55,8 @@ export declare const AutoContinueSchema: z<Schemastery.ObjectS<{
55
55
  loopWindowMs: z<number, number>;
56
56
  /** Consecutive short sentences trip the loop guard. */
57
57
  loopShortCount: z<number, number>;
58
+ /** Consecutive identical short sentences trip the loop guard (strongest spinning signal). */
59
+ loopRepeatText: z<number, number>;
58
60
  /** Consecutive identical tool calls with identical arguments AND results trip the loop guard. */
59
61
  loopToolRepeat: z<number, number>;
60
62
  /** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
@@ -106,6 +108,8 @@ export declare const AutoContinueSchema: z<Schemastery.ObjectS<{
106
108
  loopWindowMs: z<number, number>;
107
109
  /** Consecutive short sentences trip the loop guard. */
108
110
  loopShortCount: z<number, number>;
111
+ /** Consecutive identical short sentences trip the loop guard (strongest spinning signal). */
112
+ loopRepeatText: z<number, number>;
109
113
  /** Consecutive identical tool calls with identical arguments AND results trip the loop guard. */
110
114
  loopToolRepeat: z<number, number>;
111
115
  /** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-client-auto-continue",
3
3
  "description": "DSH Web UI plugin: automatically sends \"继续\" (continue) when a request is interrupted by network errors or other non-human causes",
4
- "version": "0.7.2",
4
+ "version": "0.7.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -70,6 +70,8 @@ export interface AutoContinueSettings {
70
70
  loopShortCount?: number;
71
71
  /** Consecutive identical tool calls with identical arguments AND identical results trip the loop guard. */
72
72
  loopToolRepeat?: number;
73
+ /** Consecutive identical short sentences trip the loop guard (strongest spinning signal). */
74
+ loopRepeatText?: number;
73
75
  /** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
74
76
  loopText?: string;
75
77
  }
@@ -102,6 +104,7 @@ export const DEFAULT_CONFIG: AutoContinueConfig = {
102
104
  loopShortChars: 40,
103
105
  loopWindowMs: 30000,
104
106
  loopShortCount: 12,
107
+ loopRepeatText: 4,
105
108
  loopToolRepeat: 5,
106
109
  loopText: '(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)',
107
110
  };
@@ -157,6 +160,7 @@ export function resolveConfig(section: AutoContinueSettings | undefined): AutoCo
157
160
  loopShortChars: Math.max(1, numberOr(value.loopShortChars, DEFAULT_CONFIG.loopShortChars)),
158
161
  loopWindowMs: Math.max(1000, numberOr(value.loopWindowMs, DEFAULT_CONFIG.loopWindowMs)),
159
162
  loopShortCount: Math.max(2, numberOr(value.loopShortCount, DEFAULT_CONFIG.loopShortCount)),
163
+ loopRepeatText: Math.max(2, numberOr(value.loopRepeatText, DEFAULT_CONFIG.loopRepeatText)),
160
164
  loopToolRepeat: Math.max(2, numberOr(value.loopToolRepeat, DEFAULT_CONFIG.loopToolRepeat)),
161
165
  loopText:
162
166
  typeof value.loopText === 'string' && value.loopText.trim() !== ''
@@ -386,38 +390,118 @@ function clientTimeZone(): string | undefined {
386
390
  const lockPrefix = 'dsh-auto-continue:';
387
391
  const lockKey = (sessionId: SessionId) => `${lockPrefix}lock:${sessionId}`;
388
392
  const stampKey = (sessionId: SessionId) => `${lockPrefix}last:${sessionId}`;
393
+ const countKey = (sessionId: SessionId) => `${lockPrefix}count:${sessionId}`;
389
394
 
390
- /** 尝试独占本次发送: 两个标签页同时触发时只有一个成功。 */
391
- function claimSend(sessionId: SessionId): boolean {
395
+ /**
396
+ * 上次自动发送的完整记录(跨标签页): 时间戳 + 文本。
397
+ * 回显识别用它而不是单 runner 内存, 所以任何标签页发出的消息都能被所有标签页认出。
398
+ */
399
+ function readLastSent(sessionId: SessionId): { at: number; text: string } {
392
400
  try {
393
- const token = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
394
- localStorage.setItem(lockKey(sessionId), token);
395
- return localStorage.getItem(lockKey(sessionId)) === token;
401
+ const raw = localStorage.getItem(stampKey(sessionId));
402
+ if (raw === null) return { at: 0, text: '' };
403
+ const parsed = JSON.parse(raw);
404
+ if (typeof parsed === 'object' && parsed !== null && typeof parsed.text === 'string') {
405
+ return { at: Number(parsed.at) || 0, text: parsed.text };
406
+ }
407
+ return { at: Number(raw) || 0, text: '' }; // 兼容旧格式(纯时间戳)
396
408
  } catch {
397
- return true; // 存储不可用(隐私模式等)时放行
409
+ return { at: 0, text: '' };
398
410
  }
399
411
  }
400
412
 
401
- function releaseSend(sessionId: SessionId): void {
413
+ /** 读「上次自动发送」时间戳(跨标签页冷却) */
414
+ function readLastSend(sessionId: SessionId): number {
415
+ return readLastSent(sessionId).at;
416
+ }
417
+
418
+ function writeLastSend(sessionId: SessionId, at: number, text: string): void {
402
419
  try {
403
- localStorage.removeItem(lockKey(sessionId));
420
+ localStorage.setItem(stampKey(sessionId), JSON.stringify({ at, text }));
404
421
  } catch {
405
422
  /* ignore */
406
423
  }
407
424
  }
408
425
 
409
- /** 读/写「上次自动发送」时间戳(跨标签页冷却) */
410
- function readLastSend(sessionId: SessionId): number {
426
+ // ---------- 跨标签页发送计数(硬上限, 不依赖回显识别) ----------
427
+
428
+ /** 发送计数窗口: 超过该时长无新发送, 计数自动失效。 */
429
+ const SEND_COUNT_WINDOW_MS = 10 * 60 * 1000;
430
+
431
+ interface SendCount {
432
+ at: number;
433
+ count: number;
434
+ }
435
+
436
+ function readSendCount(sessionId: SessionId): SendCount {
411
437
  try {
412
- return Number(localStorage.getItem(stampKey(sessionId)) ?? 0) || 0;
438
+ const raw = localStorage.getItem(countKey(sessionId));
439
+ if (raw === null) return { at: 0, count: 0 };
440
+ const parsed = JSON.parse(raw);
441
+ if (typeof parsed === 'object' && parsed !== null && typeof parsed.count === 'number') {
442
+ const at = Number(parsed.at) || 0;
443
+ if (Date.now() - at > SEND_COUNT_WINDOW_MS) return { at: 0, count: 0 }; // 窗口过期
444
+ return { at, count: parsed.count };
445
+ }
413
446
  } catch {
414
- return 0;
447
+ /* ignore */
415
448
  }
449
+ return { at: 0, count: 0 };
416
450
  }
417
451
 
418
- function writeLastSend(sessionId: SessionId, at: number): void {
452
+ function bumpSendCount(sessionId: SessionId): void {
419
453
  try {
420
- localStorage.setItem(stampKey(sessionId), String(at));
454
+ const current = readSendCount(sessionId);
455
+ localStorage.setItem(
456
+ countKey(sessionId),
457
+ JSON.stringify({ at: Date.now(), count: current.count + 1 }),
458
+ );
459
+ } catch {
460
+ /* ignore */
461
+ }
462
+ }
463
+
464
+ /** 用户介入或成功回合后清零发送计数(跨标签页共享)。 */
465
+ function clearSendCount(sessionId: SessionId): void {
466
+ try {
467
+ localStorage.removeItem(countKey(sessionId));
468
+ } catch {
469
+ /* ignore */
470
+ }
471
+ }
472
+
473
+ /** Web Locks: 跨标签页原子发送锁; 不可用(旧浏览器/测试环境)时回退到互斥戳。 */
474
+ async function withSendLock(sessionId: SessionId, body: () => Promise<void>): Promise<void> {
475
+ const nav = (globalThis as { navigator?: unknown }).navigator as
476
+ | { locks?: { request: (name: string, cb: () => Promise<void>) => Promise<void> } }
477
+ | undefined;
478
+ if (nav?.locks !== undefined) {
479
+ await nav.locks.request(`dsh-auto-continue:send:${sessionId}`, body);
480
+ return;
481
+ }
482
+ // 回退: 尽力互斥(单标签页下可靠; 旧浏览器无 Web Locks)
483
+ if (!claimSend(sessionId)) return;
484
+ try {
485
+ await body();
486
+ } finally {
487
+ releaseSend(sessionId);
488
+ }
489
+ }
490
+
491
+ /** 尝试独占本次发送: 两个标签页同时触发时只有一个成功(Web Locks 的回退方案)。 */
492
+ function claimSend(sessionId: SessionId): boolean {
493
+ try {
494
+ const token = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
495
+ localStorage.setItem(lockKey(sessionId), token);
496
+ return localStorage.getItem(lockKey(sessionId)) === token;
497
+ } catch {
498
+ return true; // 存储不可用(隐私模式等)时放行
499
+ }
500
+ }
501
+
502
+ function releaseSend(sessionId: SessionId): void {
503
+ try {
504
+ localStorage.removeItem(lockKey(sessionId));
421
505
  } catch {
422
506
  /* ignore */
423
507
  }
@@ -602,6 +686,10 @@ interface SessionState {
602
686
  shortRun: number;
603
687
  /** 最后一条短句的时间(时间窗判定用)。 */
604
688
  lastShortAt: number;
689
+ /** 最后一条模型消息的文本(相同文本重复判定用)。 */
690
+ lastAssistantText: string;
691
+ /** 连续相同文本消息数(最强空转信号, 不限长度)。 */
692
+ sameTextRun: number;
605
693
  /**
606
694
  * 工具重复信号(loop guard 信号 2: 死循环)。
607
695
  * 只有「同工具 + 同参数 + 同结果」的连续调用才累计; 参数或结果有变化视为有进展, 计数重置。
@@ -620,6 +708,8 @@ interface SessionState {
620
708
  | undefined;
621
709
  /** 本回合已触发过 loop guard(防重复打断)。 */
622
710
  loopFired: boolean;
711
+ /** loop 重启的延迟定时器(冷却结束后再 schedule)。 */
712
+ loopRetryTimer: number | undefined;
623
713
  /** 我们主动 cancel 过本回合(区分用户停止)。 */
624
714
  loopCancelled: boolean;
625
715
  }
@@ -641,26 +731,37 @@ const freshState = (): SessionState => ({
641
731
  pendingRecoveryAt: 0,
642
732
  shortRun: 0,
643
733
  lastShortAt: 0,
734
+ lastAssistantText: '',
735
+ sameTextRun: 0,
644
736
  toolRun: undefined,
645
737
  loopFired: false,
646
738
  loopCancelled: false,
739
+ loopRetryTimer: undefined,
647
740
  });
648
741
 
649
742
  /** 自动发送后, 在该窗口内出现的回合结束才计入恢复统计。 */
650
743
  const RECOVERY_WINDOW_MS = 10 * 60 * 1000;
651
744
 
652
- /** 判定一条 user/message 是否是我们自己自动发送的回显。 */
653
- function isOurEcho(state: SessionState, event: SessionEvent): boolean {
745
+ /** 回显识别窗口: 排队消息可能几分钟后才被模型处理到, 窗口必须远大于排队延迟。 */
746
+ const ECHO_WINDOW_MS = 10 * 60 * 1000;
747
+
748
+ /**
749
+ * 判定一条 user/message 是否是我们自己自动发送的回显。
750
+ * 用 localStorage 里的上次发送记录(跨标签页): 任何标签页发出的消息,
751
+ * 所有标签页都能认出——排队回显不会误判为「用户介入」而清零上限。
752
+ */
753
+ function isOurEcho(state: SessionState, sessionId: SessionId, event: SessionEvent): boolean {
654
754
  if (event.type !== 'user/message') return false;
655
755
  const message = event.data;
656
756
  if (message.source.kind !== 'user') return false;
657
- if (state.lastSentText === '') return false;
658
- if (Date.now() - state.lastAutoAt > 30000) return false;
757
+ const last = readLastSent(sessionId);
758
+ if (last.at === 0 || last.text === '') return false;
759
+ if (Date.now() - last.at > ECHO_WINDOW_MS) return false;
659
760
  const text = message.content
660
761
  .filter((part): part is { type: 'text'; text: string } => part.type === 'text')
661
762
  .map((part) => part.text)
662
763
  .join('');
663
- return text === state.lastSentText;
764
+ return text === last.text;
664
765
  }
665
766
 
666
767
  /** SSE 帧外壳: `{ rpcId, payload }`。 */
@@ -744,6 +845,7 @@ export class AutoContinueRunner {
744
845
  this.hostAbort.abort();
745
846
  for (const state of this.states.values()) {
746
847
  if (state.pendingTimer !== undefined) clearTimeout(state.pendingTimer);
848
+ if (state.loopRetryTimer !== undefined) clearTimeout(state.loopRetryTimer);
747
849
  }
748
850
  this.states.clear();
749
851
  }
@@ -860,7 +962,17 @@ export class AutoContinueRunner {
860
962
  ): void {
861
963
  if (!this.getConfig().loopGuard) return;
862
964
  const text = this.assistantText(event);
863
- if (text.trim().length < this.getConfig().loopShortChars) {
965
+ const trimmed = text.trim();
966
+ // 相同文本重复(不限长度): 模型反复输出完全相同的消息是最强的循环信号,
967
+ // 例如 "Let me test variants of the regex..." 连续 7 遍
968
+ if (trimmed !== '' && trimmed === state.lastAssistantText) {
969
+ state.sameTextRun += 1;
970
+ } else {
971
+ state.lastAssistantText = trimmed;
972
+ state.sameTextRun = 1;
973
+ }
974
+ // 短句计数(长度 < loopShortChars 且落在时间窗内): 空转信号
975
+ if (trimmed.length < this.getConfig().loopShortChars) {
864
976
  const now = Date.now();
865
977
  if (now - state.lastShortAt > this.getConfig().loopWindowMs) {
866
978
  state.shortRun = 0; // 超过时间窗: 上一次短句太久远, 不算连续
@@ -880,7 +992,10 @@ export class AutoContinueRunner {
880
992
  if (state.loopFired) return;
881
993
  if (!state.running) return; // 只干预运行中的回合
882
994
  const config = this.getConfig();
883
- if (state.shortRun >= config.loopShortCount) {
995
+ if (state.sameTextRun >= config.loopRepeatText) {
996
+ this.log(`检测到空转循环 ${sessionId}: 连续 ${state.sameTextRun} 条相同消息`);
997
+ void this.interruptLoop(sessionId, state);
998
+ } else if (state.shortRun >= config.loopShortCount) {
884
999
  this.log(`检测到空转循环 ${sessionId}: 连续 ${state.shortRun} 条短句且无工具调用`);
885
1000
  void this.interruptLoop(sessionId, state);
886
1001
  } else if (state.toolRun !== undefined && state.toolRun.count >= config.loopToolRepeat) {
@@ -928,9 +1043,15 @@ export class AutoContinueRunner {
928
1043
  // loop guard 状态按回合重置
929
1044
  state.shortRun = 0;
930
1045
  state.lastShortAt = 0;
1046
+ state.lastAssistantText = '';
1047
+ state.sameTextRun = 0;
931
1048
  state.toolRun = undefined;
932
1049
  state.loopFired = false;
933
1050
  state.loopCancelled = false;
1051
+ if (state.loopRetryTimer !== undefined) {
1052
+ clearTimeout(state.loopRetryTimer);
1053
+ state.loopRetryTimer = undefined;
1054
+ }
934
1055
  this.cancelPending(sessionId, '宿主自行开启新回合');
935
1056
  break;
936
1057
  case 'turn/end': {
@@ -941,24 +1062,39 @@ export class AutoContinueRunner {
941
1062
  // 成功回合: 恢复健康状态, 并确认上一次自动发送的效果
942
1063
  state.consecutive = 0;
943
1064
  state.lastFailure = undefined;
1065
+ clearSendCount(sessionId);
944
1066
  this.noteRecovery(sessionId, 'completed');
945
1067
  } else if (reason.kind === 'aborted') {
946
1068
  if (state.loopCancelled) {
947
1069
  // 我们自己的 loop guard 打断: 视为可恢复中断, 用循环提示文本重启回合。
948
- // 重启不受冷却限制(冷却约束的是"再次打断")。
1070
+ // 不清 consecutive / lastAttemptAt: 冷却与连续上限在 loop 路径同样生效,
1071
+ // 防止无限打断重发(issue #13); 打断本身受冷却约束, 重启也要等冷却。
949
1072
  state.loopCancelled = false;
950
1073
  state.loopFired = false;
951
- state.consecutive = 0;
952
1074
  state.pendingRecoveryAt = 0;
953
1075
  state.shortRun = 0;
954
1076
  state.lastShortAt = 0;
1077
+ state.lastAssistantText = '';
1078
+ state.sameTextRun = 0;
955
1079
  state.toolRun = undefined;
956
- state.lastAttemptAt = 0;
957
- this.schedule(sessionId, 'loop:aborted');
1080
+ // 重启受冷却约束(防紧密打断循环): 等剩余冷却结束后再调度
1081
+ const cooldown = this.cooldownFor(state);
1082
+ const remaining = cooldown - (Date.now() - state.lastAttemptAt);
1083
+ if (remaining > 0) {
1084
+ if (state.loopRetryTimer !== undefined) clearTimeout(state.loopRetryTimer);
1085
+ state.loopRetryTimer = setTimeout(() => {
1086
+ state.loopRetryTimer = undefined;
1087
+ this.schedule(sessionId, 'loop:aborted');
1088
+ }, remaining);
1089
+ this.log(`loop 重启延迟 ${remaining}ms(冷却期) ${sessionId}`);
1090
+ } else {
1091
+ this.schedule(sessionId, 'loop:aborted');
1092
+ }
958
1093
  } else {
959
1094
  // 用户主动停止: 不自动继续, 视为用户介入
960
1095
  state.consecutive = 0;
961
1096
  state.pendingRecoveryAt = 0;
1097
+ clearSendCount(sessionId);
962
1098
  }
963
1099
  } else if (reason.kind === 'blocked') {
964
1100
  // 策略拒绝: 不自动继续
@@ -988,10 +1124,11 @@ export class AutoContinueRunner {
988
1124
  break;
989
1125
  }
990
1126
  case 'user/message':
991
- if (isOurEcho(state, event)) break; // 我们自己的回显
1127
+ if (isOurEcho(state, sessionId, event)) break; // 我们自己的回显(跨标签页识别)
992
1128
  if (event.data.source.kind === 'user') {
993
- // 用户手动介入
1129
+ // 用户手动介入: 清零上限与跨标签页发送计数
994
1130
  state.consecutive = 0;
1131
+ clearSendCount(sessionId);
995
1132
  this.cancelPending(sessionId, '用户手动发送消息');
996
1133
  }
997
1134
  break;
@@ -1185,13 +1322,10 @@ export class AutoContinueRunner {
1185
1322
  this.log(`跳过 ${sessionId}: 已有排队消息`);
1186
1323
  return;
1187
1324
  }
1188
- // 跨标签页冷却(自适应退避); 通知按钮的强制续跑不受冷却约束
1189
- if (!force && Date.now() - readLastSend(sessionId) < this.cooldownFor(state)) {
1190
- this.log(`跳过 ${sessionId}: 其他标签页刚发送过`);
1191
- return;
1192
- }
1193
- if (!claimSend(sessionId)) {
1194
- this.log(`跳过 ${sessionId}: 其他标签页正在发送`);
1325
+ // 跨标签页持久化发送计数: 窗口内达到 maxConsecutive 即硬抑制,
1326
+ // 不依赖回显识别与单 runner 内存(issue #13 的多标签页刷屏防线)
1327
+ if (!force && readSendCount(sessionId).count >= config.maxConsecutive) {
1328
+ this.log(`跳过 ${sessionId}: 发送计数已达上限 ${config.maxConsecutive}, 等待用户介入或成功回合`);
1195
1329
  return;
1196
1330
  }
1197
1331
  // 模板填充: continueText 可含 {code}/{message}/{status}/{tool}/{turn}/{errorCount}/{sessionTitle}/{elapsed} 占位符
@@ -1210,51 +1344,68 @@ export class AutoContinueRunner {
1210
1344
  }
1211
1345
  const text = this.buildContinueText(config, state, template, sessionTitle);
1212
1346
  const zone = clientTimeZone();
1213
- state.lastAttemptAt = Date.now(); // 先记账: 无论成败, 本次尝试都进入冷却
1214
- try {
1215
- const response = await this.api.sessions.prompt({
1216
- sessionId,
1217
- mode: 'queue',
1218
- content: [{ type: 'text', text }],
1219
- ...(zone === undefined ? {} : { clientTimeZone: zone }),
1220
- });
1221
- if (response.result.ok) {
1222
- const now = Date.now();
1223
- state.consecutive += 1;
1224
- state.lastAutoAt = now;
1225
- state.lastSentText = text;
1226
- state.pendingRecoveryAt = now; // 等待窗口内的下一个回合结束来判定恢复结果
1227
- writeLastSend(sessionId, now);
1228
- bumpStat({ sent: 1, ...(state.lastFailure !== undefined ? { code: state.lastFailure.code } : {}) });
1229
- this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
1230
- if (config.notify) {
1231
- notify(
1232
- 'dsh-auto-continue: 已自动继续',
1233
- `${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`,
1234
- this.notifyOptions(sessionId),
1235
- );
1236
- }
1237
- if (state.consecutive >= config.maxConsecutive) {
1238
- bumpStat({ gaveUp: 1 });
1239
- this.log(`达到连续上限 ${config.maxConsecutive} 次, 停止自动继续 ${sessionId}`);
1347
+ // 跨标签页原子发送锁(Web Locks; 回退到互斥戳):
1348
+ // 冷却检查、发送、计数、时间戳全部在锁内, 两个标签页不会同时放行。
1349
+ await withSendLock(sessionId, async () => {
1350
+ if (this.disposed) return;
1351
+ if (state.queued > 0) {
1352
+ this.log(`跳过 ${sessionId}: 已有排队消息`);
1353
+ return;
1354
+ }
1355
+ // 跨标签页冷却(自适应退避); 通知按钮的强制续跑不受冷却约束
1356
+ if (!force && Date.now() - readLastSend(sessionId) < this.cooldownFor(state)) {
1357
+ this.log(`跳过 ${sessionId}: 其他标签页刚发送过`);
1358
+ return;
1359
+ }
1360
+ if (!force && readSendCount(sessionId).count >= config.maxConsecutive) {
1361
+ this.log(`跳过 ${sessionId}: 发送计数已达上限 ${config.maxConsecutive}, 等待用户介入或成功回合`);
1362
+ return;
1363
+ }
1364
+ state.lastAttemptAt = Date.now(); // 先记账: 无论成败, 本次尝试都进入冷却
1365
+ try {
1366
+ const response = await this.api.sessions.prompt({
1367
+ sessionId,
1368
+ mode: 'queue',
1369
+ content: [{ type: 'text', text }],
1370
+ ...(zone === undefined ? {} : { clientTimeZone: zone }),
1371
+ });
1372
+ if (response.result.ok) {
1373
+ const now = Date.now();
1374
+ state.consecutive += 1;
1375
+ state.lastAutoAt = now;
1376
+ state.lastSentText = text;
1377
+ state.pendingRecoveryAt = now; // 等待窗口内的下一个回合结束来判定恢复结果
1378
+ writeLastSend(sessionId, now, text); // 记录文本: 跨标签页回显识别
1379
+ bumpSendCount(sessionId); // 跨标签页持久化计数(硬上限)
1380
+ bumpStat({ sent: 1, ...(state.lastFailure !== undefined ? { code: state.lastFailure.code } : {}) });
1381
+ this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
1240
1382
  if (config.notify) {
1241
1383
  notify(
1242
- 'dsh-auto-continue: 已停止自动继续',
1243
- `${sessionId}: 连续失败 ${state.consecutive} 次, 需要人工介入`,
1384
+ 'dsh-auto-continue: 已自动继续',
1385
+ `${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`,
1244
1386
  this.notifyOptions(sessionId),
1245
1387
  );
1246
1388
  }
1389
+ if (state.consecutive >= config.maxConsecutive) {
1390
+ bumpStat({ gaveUp: 1 });
1391
+ this.log(`达到连续上限 ${config.maxConsecutive} 次, 停止自动继续 ${sessionId}`);
1392
+ if (config.notify) {
1393
+ notify(
1394
+ 'dsh-auto-continue: 已停止自动继续',
1395
+ `${sessionId}: 连续失败 ${state.consecutive} 次, 需要人工介入`,
1396
+ this.notifyOptions(sessionId),
1397
+ );
1398
+ }
1399
+ }
1400
+ } else {
1401
+ this.log(
1402
+ `发送失败 ${sessionId}: ${response.result.error.code} ${response.result.error.message}`,
1403
+ );
1247
1404
  }
1248
- } else {
1249
- this.log(
1250
- `发送失败 ${sessionId}: ${response.result.error.code} ${response.result.error.message}`,
1251
- );
1405
+ } catch (error) {
1406
+ this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
1252
1407
  }
1253
- } catch (error) {
1254
- this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
1255
- } finally {
1256
- releaseSend(sessionId);
1257
- }
1408
+ });
1258
1409
  }
1259
1410
 
1260
1411
  /**
@@ -61,6 +61,8 @@ export const zh = {
61
61
  'field.loopWindowMsHint': '连续短句必须落在这个时间窗内; 正常思考的短文本散布在长时间里不会被误判。',
62
62
  'field.loopShortCount': '连续短句阈值',
63
63
  'field.loopShortCountHint': '时间窗内连续多少条短句且期间无工具调用时判定空转循环。',
64
+ 'field.loopRepeatText': '相同消息重复次数',
65
+ 'field.loopRepeatTextHint': '连续输出多少条完全相同的消息时判定空转(最强信号, 不限长度, 如模型反复说同一句话)。',
64
66
  'field.loopToolRepeat': '同工具重复次数',
65
67
  'field.loopToolRepeatHint': '同工具+同参数+同结果的连续调用多少次时判定死循环; 参数或结果有变化视为有进展。',
66
68
  'field.loopText': '循环提示文本',
@@ -149,6 +151,8 @@ export const en: Record<SettingsCardKey, string> = {
149
151
  'field.loopWindowMsHint': 'Consecutive short sentences must land inside this window; normal thinking spread over time is not misjudged.',
150
152
  'field.loopShortCount': 'Short-sentence threshold',
151
153
  'field.loopShortCountHint': 'How many consecutive short sentences inside the window, with no tool call, trip the loop guard.',
154
+ 'field.loopRepeatText': 'Identical message count',
155
+ 'field.loopRepeatTextHint': 'How many consecutive identical messages trip the guard (strongest signal, any length, e.g. the model repeating the same line).',
152
156
  'field.loopToolRepeat': 'Same-tool repeat count',
153
157
  'field.loopToolRepeatHint': 'How many consecutive calls of the same tool with identical arguments and results trip the loop guard; a changed argument or result counts as progress.',
154
158
  'field.loopText': 'Loop text',
@@ -59,6 +59,7 @@ export interface AutoContinueSettingsCardState extends CardShell {
59
59
  loopShortChars: CardFieldState;
60
60
  loopWindowMs: CardFieldState;
61
61
  loopShortCount: CardFieldState;
62
+ loopRepeatText: CardFieldState;
62
63
  loopToolRepeat: CardFieldState;
63
64
  loopText: CardFieldState;
64
65
  }
@@ -104,6 +105,7 @@ export class AutoContinueSettingsCardController {
104
105
  numberField('loopShortChars', 1),
105
106
  numberField('loopWindowMs', 1000),
106
107
  numberField('loopShortCount', 2),
108
+ numberField('loopRepeatText', 2),
107
109
  numberField('loopToolRepeat', 2),
108
110
  textField('loopText'),
109
111
  ]);
@@ -136,6 +138,7 @@ export class AutoContinueSettingsCardController {
136
138
  loopShortChars: this.form.field('loopShortChars'),
137
139
  loopWindowMs: this.form.field('loopWindowMs'),
138
140
  loopShortCount: this.form.field('loopShortCount'),
141
+ loopRepeatText: this.form.field('loopRepeatText'),
139
142
  loopToolRepeat: this.form.field('loopToolRepeat'),
140
143
  loopText: this.form.field('loopText'),
141
144
  };
@@ -650,6 +653,16 @@ export function AutoContinueSettingsCard(props: AutoContinueSettingsCardProps) {
650
653
  onEdit={(text) => props.edit('loopToolRepeat', text)}
651
654
  onReset={() => props.resetField('loopToolRepeat')}
652
655
  />
656
+ <ValueField
657
+ id="auto-continue-loop-repeat-text"
658
+ label={t('field.loopRepeatText')}
659
+ hint={t('field.loopRepeatTextHint')}
660
+ numeric
661
+ {...shared}
662
+ {...state.loopRepeatText}
663
+ onEdit={(text) => props.edit('loopRepeatText', text)}
664
+ onReset={() => props.resetField('loopRepeatText')}
665
+ />
653
666
  <ValueField
654
667
  id="auto-continue-loop-text"
655
668
  label={t('field.loopText')}
package/src/index.ts CHANGED
@@ -64,6 +64,8 @@ export const AutoContinueSchema = z.object({
64
64
  loopWindowMs: z.natural().min(1000).default(30000),
65
65
  /** Consecutive short sentences trip the loop guard. */
66
66
  loopShortCount: z.natural().min(2).default(12),
67
+ /** Consecutive identical short sentences trip the loop guard (strongest spinning signal). */
68
+ loopRepeatText: z.natural().min(2).default(4),
67
69
  /** Consecutive identical tool calls with identical arguments AND results trip the loop guard. */
68
70
  loopToolRepeat: z.natural().min(2).default(5),
69
71
  /** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */