dsh-client-auto-continue 0.7.3 → 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/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.3",
4
+ "version": "0.7.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -390,38 +390,118 @@ function clientTimeZone(): string | undefined {
390
390
  const lockPrefix = 'dsh-auto-continue:';
391
391
  const lockKey = (sessionId: SessionId) => `${lockPrefix}lock:${sessionId}`;
392
392
  const stampKey = (sessionId: SessionId) => `${lockPrefix}last:${sessionId}`;
393
+ const countKey = (sessionId: SessionId) => `${lockPrefix}count:${sessionId}`;
393
394
 
394
- /** 尝试独占本次发送: 两个标签页同时触发时只有一个成功。 */
395
- function claimSend(sessionId: SessionId): boolean {
395
+ /**
396
+ * 上次自动发送的完整记录(跨标签页): 时间戳 + 文本。
397
+ * 回显识别用它而不是单 runner 内存, 所以任何标签页发出的消息都能被所有标签页认出。
398
+ */
399
+ function readLastSent(sessionId: SessionId): { at: number; text: string } {
396
400
  try {
397
- const token = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
398
- localStorage.setItem(lockKey(sessionId), token);
399
- 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: '' }; // 兼容旧格式(纯时间戳)
400
408
  } catch {
401
- return true; // 存储不可用(隐私模式等)时放行
409
+ return { at: 0, text: '' };
402
410
  }
403
411
  }
404
412
 
405
- 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 {
406
419
  try {
407
- localStorage.removeItem(lockKey(sessionId));
420
+ localStorage.setItem(stampKey(sessionId), JSON.stringify({ at, text }));
408
421
  } catch {
409
422
  /* ignore */
410
423
  }
411
424
  }
412
425
 
413
- /** 读/写「上次自动发送」时间戳(跨标签页冷却) */
414
- 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 {
437
+ try {
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
+ }
446
+ } catch {
447
+ /* ignore */
448
+ }
449
+ return { at: 0, count: 0 };
450
+ }
451
+
452
+ function bumpSendCount(sessionId: SessionId): void {
415
453
  try {
416
- return Number(localStorage.getItem(stampKey(sessionId)) ?? 0) || 0;
454
+ const current = readSendCount(sessionId);
455
+ localStorage.setItem(
456
+ countKey(sessionId),
457
+ JSON.stringify({ at: Date.now(), count: current.count + 1 }),
458
+ );
417
459
  } catch {
418
- return 0;
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);
419
488
  }
420
489
  }
421
490
 
422
- function writeLastSend(sessionId: SessionId, at: number): void {
491
+ /** 尝试独占本次发送: 两个标签页同时触发时只有一个成功(Web Locks 的回退方案) */
492
+ function claimSend(sessionId: SessionId): boolean {
423
493
  try {
424
- localStorage.setItem(stampKey(sessionId), String(at));
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));
425
505
  } catch {
426
506
  /* ignore */
427
507
  }
@@ -628,6 +708,8 @@ interface SessionState {
628
708
  | undefined;
629
709
  /** 本回合已触发过 loop guard(防重复打断)。 */
630
710
  loopFired: boolean;
711
+ /** loop 重启的延迟定时器(冷却结束后再 schedule)。 */
712
+ loopRetryTimer: number | undefined;
631
713
  /** 我们主动 cancel 过本回合(区分用户停止)。 */
632
714
  loopCancelled: boolean;
633
715
  }
@@ -654,23 +736,32 @@ const freshState = (): SessionState => ({
654
736
  toolRun: undefined,
655
737
  loopFired: false,
656
738
  loopCancelled: false,
739
+ loopRetryTimer: undefined,
657
740
  });
658
741
 
659
742
  /** 自动发送后, 在该窗口内出现的回合结束才计入恢复统计。 */
660
743
  const RECOVERY_WINDOW_MS = 10 * 60 * 1000;
661
744
 
662
- /** 判定一条 user/message 是否是我们自己自动发送的回显。 */
663
- 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 {
664
754
  if (event.type !== 'user/message') return false;
665
755
  const message = event.data;
666
756
  if (message.source.kind !== 'user') return false;
667
- if (state.lastSentText === '') return false;
668
- 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;
669
760
  const text = message.content
670
761
  .filter((part): part is { type: 'text'; text: string } => part.type === 'text')
671
762
  .map((part) => part.text)
672
763
  .join('');
673
- return text === state.lastSentText;
764
+ return text === last.text;
674
765
  }
675
766
 
676
767
  /** SSE 帧外壳: `{ rpcId, payload }`。 */
@@ -754,6 +845,7 @@ export class AutoContinueRunner {
754
845
  this.hostAbort.abort();
755
846
  for (const state of this.states.values()) {
756
847
  if (state.pendingTimer !== undefined) clearTimeout(state.pendingTimer);
848
+ if (state.loopRetryTimer !== undefined) clearTimeout(state.loopRetryTimer);
757
849
  }
758
850
  this.states.clear();
759
851
  }
@@ -956,6 +1048,10 @@ export class AutoContinueRunner {
956
1048
  state.toolRun = undefined;
957
1049
  state.loopFired = false;
958
1050
  state.loopCancelled = false;
1051
+ if (state.loopRetryTimer !== undefined) {
1052
+ clearTimeout(state.loopRetryTimer);
1053
+ state.loopRetryTimer = undefined;
1054
+ }
959
1055
  this.cancelPending(sessionId, '宿主自行开启新回合');
960
1056
  break;
961
1057
  case 'turn/end': {
@@ -966,26 +1062,39 @@ export class AutoContinueRunner {
966
1062
  // 成功回合: 恢复健康状态, 并确认上一次自动发送的效果
967
1063
  state.consecutive = 0;
968
1064
  state.lastFailure = undefined;
1065
+ clearSendCount(sessionId);
969
1066
  this.noteRecovery(sessionId, 'completed');
970
1067
  } else if (reason.kind === 'aborted') {
971
1068
  if (state.loopCancelled) {
972
1069
  // 我们自己的 loop guard 打断: 视为可恢复中断, 用循环提示文本重启回合。
973
- // 重启不受冷却限制(冷却约束的是"再次打断")。
1070
+ // 不清 consecutive / lastAttemptAt: 冷却与连续上限在 loop 路径同样生效,
1071
+ // 防止无限打断重发(issue #13); 打断本身受冷却约束, 重启也要等冷却。
974
1072
  state.loopCancelled = false;
975
1073
  state.loopFired = false;
976
- state.consecutive = 0;
977
1074
  state.pendingRecoveryAt = 0;
978
1075
  state.shortRun = 0;
979
1076
  state.lastShortAt = 0;
980
1077
  state.lastAssistantText = '';
981
1078
  state.sameTextRun = 0;
982
1079
  state.toolRun = undefined;
983
- state.lastAttemptAt = 0;
984
- 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
+ }
985
1093
  } else {
986
1094
  // 用户主动停止: 不自动继续, 视为用户介入
987
1095
  state.consecutive = 0;
988
1096
  state.pendingRecoveryAt = 0;
1097
+ clearSendCount(sessionId);
989
1098
  }
990
1099
  } else if (reason.kind === 'blocked') {
991
1100
  // 策略拒绝: 不自动继续
@@ -1015,10 +1124,11 @@ export class AutoContinueRunner {
1015
1124
  break;
1016
1125
  }
1017
1126
  case 'user/message':
1018
- if (isOurEcho(state, event)) break; // 我们自己的回显
1127
+ if (isOurEcho(state, sessionId, event)) break; // 我们自己的回显(跨标签页识别)
1019
1128
  if (event.data.source.kind === 'user') {
1020
- // 用户手动介入
1129
+ // 用户手动介入: 清零上限与跨标签页发送计数
1021
1130
  state.consecutive = 0;
1131
+ clearSendCount(sessionId);
1022
1132
  this.cancelPending(sessionId, '用户手动发送消息');
1023
1133
  }
1024
1134
  break;
@@ -1212,13 +1322,10 @@ export class AutoContinueRunner {
1212
1322
  this.log(`跳过 ${sessionId}: 已有排队消息`);
1213
1323
  return;
1214
1324
  }
1215
- // 跨标签页冷却(自适应退避); 通知按钮的强制续跑不受冷却约束
1216
- if (!force && Date.now() - readLastSend(sessionId) < this.cooldownFor(state)) {
1217
- this.log(`跳过 ${sessionId}: 其他标签页刚发送过`);
1218
- return;
1219
- }
1220
- if (!claimSend(sessionId)) {
1221
- this.log(`跳过 ${sessionId}: 其他标签页正在发送`);
1325
+ // 跨标签页持久化发送计数: 窗口内达到 maxConsecutive 即硬抑制,
1326
+ // 不依赖回显识别与单 runner 内存(issue #13 的多标签页刷屏防线)
1327
+ if (!force && readSendCount(sessionId).count >= config.maxConsecutive) {
1328
+ this.log(`跳过 ${sessionId}: 发送计数已达上限 ${config.maxConsecutive}, 等待用户介入或成功回合`);
1222
1329
  return;
1223
1330
  }
1224
1331
  // 模板填充: continueText 可含 {code}/{message}/{status}/{tool}/{turn}/{errorCount}/{sessionTitle}/{elapsed} 占位符
@@ -1237,51 +1344,68 @@ export class AutoContinueRunner {
1237
1344
  }
1238
1345
  const text = this.buildContinueText(config, state, template, sessionTitle);
1239
1346
  const zone = clientTimeZone();
1240
- state.lastAttemptAt = Date.now(); // 先记账: 无论成败, 本次尝试都进入冷却
1241
- try {
1242
- const response = await this.api.sessions.prompt({
1243
- sessionId,
1244
- mode: 'queue',
1245
- content: [{ type: 'text', text }],
1246
- ...(zone === undefined ? {} : { clientTimeZone: zone }),
1247
- });
1248
- if (response.result.ok) {
1249
- const now = Date.now();
1250
- state.consecutive += 1;
1251
- state.lastAutoAt = now;
1252
- state.lastSentText = text;
1253
- state.pendingRecoveryAt = now; // 等待窗口内的下一个回合结束来判定恢复结果
1254
- writeLastSend(sessionId, now);
1255
- bumpStat({ sent: 1, ...(state.lastFailure !== undefined ? { code: state.lastFailure.code } : {}) });
1256
- this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
1257
- if (config.notify) {
1258
- notify(
1259
- 'dsh-auto-continue: 已自动继续',
1260
- `${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`,
1261
- this.notifyOptions(sessionId),
1262
- );
1263
- }
1264
- if (state.consecutive >= config.maxConsecutive) {
1265
- bumpStat({ gaveUp: 1 });
1266
- 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} 次连续`);
1267
1382
  if (config.notify) {
1268
1383
  notify(
1269
- 'dsh-auto-continue: 已停止自动继续',
1270
- `${sessionId}: 连续失败 ${state.consecutive} 次, 需要人工介入`,
1384
+ 'dsh-auto-continue: 已自动继续',
1385
+ `${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`,
1271
1386
  this.notifyOptions(sessionId),
1272
1387
  );
1273
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
+ );
1274
1404
  }
1275
- } else {
1276
- this.log(
1277
- `发送失败 ${sessionId}: ${response.result.error.code} ${response.result.error.message}`,
1278
- );
1405
+ } catch (error) {
1406
+ this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
1279
1407
  }
1280
- } catch (error) {
1281
- this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
1282
- } finally {
1283
- releaseSend(sessionId);
1284
- }
1408
+ });
1285
1409
  }
1286
1410
 
1287
1411
  /**