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/README.md CHANGED
@@ -232,7 +232,7 @@ The plugin is browser-only and touches **no files, credentials, or network beyon
232
232
 
233
233
  - It opens the same two read-only event streams the web UI already uses (no extra server, no third-party endpoints)
234
234
  - The engine's **only automatic write** is `sessions.prompt` — the same call the Send button makes — with the text you configured (saving the settings card writes the `auto-continue` section of `~/.dsh/settings.yaml` through the normal settings API, exactly like any other setting)
235
- - Browser storage is limited to small `localStorage` keys: cross-tab coordination stamps, per-session pauses, and the daily stats counters
235
+ - Browser storage is limited to small `localStorage` keys: cross-tab send locks and counts (a hard cap that stops duplicate sends even with several tabs open), per-session pauses, and the daily stats counters
236
236
  - Browser notifications are opt-in (`notify` setting) and permission is requested on first use only
237
237
 
238
238
  ---
@@ -243,7 +243,7 @@ The plugin is browser-only and touches **no files, credentials, or network beyon
243
243
  npm run typecheck # tsc --noEmit
244
244
  npm run build # lib/client.js + lib/index.js + lib/types
245
245
  npm run watch # rebuild on change; host HMR hot-reloads without a page refresh
246
- npm run test # node tests/simulate.mjs — 38 behavioral scenarios
246
+ npm run test # node tests/simulate.mjs — 40 behavioral scenarios
247
247
  ```
248
248
 
249
249
  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
@@ -232,7 +232,7 @@ auto-continue:
232
232
 
233
233
  - 只复用 webui 本身就在用的两条只读事件流(无额外服务、无第三方端点)
234
234
  - 引擎**唯一会自动执行的写入**是 `sessions.prompt`——与点「发送」按钮完全相同的调用, 内容为你配置的文本(设置卡片里保存配置会通过常规设置 API 写入 `~/.dsh/settings.yaml` 的 `auto-continue` 段落, 与任何其他设置一样)
235
- - 浏览器存储仅限于少量 `localStorage` 键: 跨标签页协调时间戳、会话级暂停、每日统计计数
235
+ - 浏览器存储仅限于少量 `localStorage` 键: 跨标签页发送锁与发送计数(多标签页同时打开时也绝不重复刷屏的硬上限)、会话级暂停、每日统计计数
236
236
  - 浏览器通知是可选开启的(`notify` 设置), 仅在首次使用时请求一次权限
237
237
 
238
238
  ---
@@ -243,7 +243,7 @@ auto-continue:
243
243
  npm run typecheck # tsc --noEmit
244
244
  npm run build # lib/client.js + lib/index.js + lib/types
245
245
  npm run watch # 监听变更自动重建; 宿主 HMR 免刷新热重载
246
- npm run test # node tests/simulate.mjs — 38 个行为场景
246
+ npm run test # node tests/simulate.mjs — 40 个行为场景
247
247
  ```
248
248
 
249
249
  `npm run watch` 运行时, profile 的 client-hmr 行每 500ms 轮询 `lib/client.js` 并在浏览器中热重载插件——改代码无需重启服务。
package/lib/client.js CHANGED
@@ -204,31 +204,85 @@ function clientTimeZone() {
204
204
  var lockPrefix = "dsh-auto-continue:";
205
205
  var lockKey = (sessionId) => `${lockPrefix}lock:${sessionId}`;
206
206
  var stampKey = (sessionId) => `${lockPrefix}last:${sessionId}`;
207
- function claimSend(sessionId) {
207
+ var countKey = (sessionId) => `${lockPrefix}count:${sessionId}`;
208
+ function readLastSent(sessionId) {
208
209
  try {
209
- const token = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
210
- localStorage.setItem(lockKey(sessionId), token);
211
- return localStorage.getItem(lockKey(sessionId)) === token;
210
+ const raw = localStorage.getItem(stampKey(sessionId));
211
+ if (raw === null) return { at: 0, text: "" };
212
+ const parsed = JSON.parse(raw);
213
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.text === "string") {
214
+ return { at: Number(parsed.at) || 0, text: parsed.text };
215
+ }
216
+ return { at: Number(raw) || 0, text: "" };
212
217
  } catch {
213
- return true;
218
+ return { at: 0, text: "" };
214
219
  }
215
220
  }
216
- function releaseSend(sessionId) {
221
+ function readLastSend(sessionId) {
222
+ return readLastSent(sessionId).at;
223
+ }
224
+ function writeLastSend(sessionId, at, text) {
217
225
  try {
218
- localStorage.removeItem(lockKey(sessionId));
226
+ localStorage.setItem(stampKey(sessionId), JSON.stringify({ at, text }));
219
227
  } catch {
220
228
  }
221
229
  }
222
- function readLastSend(sessionId) {
230
+ var SEND_COUNT_WINDOW_MS = 10 * 60 * 1e3;
231
+ function readSendCount(sessionId) {
223
232
  try {
224
- return Number(localStorage.getItem(stampKey(sessionId)) ?? 0) || 0;
233
+ const raw = localStorage.getItem(countKey(sessionId));
234
+ if (raw === null) return { at: 0, count: 0 };
235
+ const parsed = JSON.parse(raw);
236
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.count === "number") {
237
+ const at = Number(parsed.at) || 0;
238
+ if (Date.now() - at > SEND_COUNT_WINDOW_MS) return { at: 0, count: 0 };
239
+ return { at, count: parsed.count };
240
+ }
241
+ } catch {
242
+ }
243
+ return { at: 0, count: 0 };
244
+ }
245
+ function bumpSendCount(sessionId) {
246
+ try {
247
+ const current2 = readSendCount(sessionId);
248
+ localStorage.setItem(
249
+ countKey(sessionId),
250
+ JSON.stringify({ at: Date.now(), count: current2.count + 1 })
251
+ );
225
252
  } catch {
226
- return 0;
227
253
  }
228
254
  }
229
- function writeLastSend(sessionId, at) {
255
+ function clearSendCount(sessionId) {
230
256
  try {
231
- localStorage.setItem(stampKey(sessionId), String(at));
257
+ localStorage.removeItem(countKey(sessionId));
258
+ } catch {
259
+ }
260
+ }
261
+ async function withSendLock(sessionId, body) {
262
+ const nav = globalThis.navigator;
263
+ if (nav?.locks !== void 0) {
264
+ await nav.locks.request(`dsh-auto-continue:send:${sessionId}`, body);
265
+ return;
266
+ }
267
+ if (!claimSend(sessionId)) return;
268
+ try {
269
+ await body();
270
+ } finally {
271
+ releaseSend(sessionId);
272
+ }
273
+ }
274
+ function claimSend(sessionId) {
275
+ try {
276
+ const token = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
277
+ localStorage.setItem(lockKey(sessionId), token);
278
+ return localStorage.getItem(lockKey(sessionId)) === token;
279
+ } catch {
280
+ return true;
281
+ }
282
+ }
283
+ function releaseSend(sessionId) {
284
+ try {
285
+ localStorage.removeItem(lockKey(sessionId));
232
286
  } catch {
233
287
  }
234
288
  }
@@ -340,17 +394,20 @@ var freshState = () => ({
340
394
  sameTextRun: 0,
341
395
  toolRun: void 0,
342
396
  loopFired: false,
343
- loopCancelled: false
397
+ loopCancelled: false,
398
+ loopRetryTimer: void 0
344
399
  });
345
400
  var RECOVERY_WINDOW_MS = 10 * 60 * 1e3;
346
- function isOurEcho(state, event) {
401
+ var ECHO_WINDOW_MS = 10 * 60 * 1e3;
402
+ function isOurEcho(state, sessionId, event) {
347
403
  if (event.type !== "user/message") return false;
348
404
  const message = event.data;
349
405
  if (message.source.kind !== "user") return false;
350
- if (state.lastSentText === "") return false;
351
- if (Date.now() - state.lastAutoAt > 3e4) return false;
406
+ const last = readLastSent(sessionId);
407
+ if (last.at === 0 || last.text === "") return false;
408
+ if (Date.now() - last.at > ECHO_WINDOW_MS) return false;
352
409
  const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("");
353
- return text === state.lastSentText;
410
+ return text === last.text;
354
411
  }
355
412
  async function pumpStream(open, onFrame, onReconnect, getBackoff, log, signal) {
356
413
  let backoff = getBackoff();
@@ -410,6 +467,7 @@ var AutoContinueRunner = class {
410
467
  this.hostAbort.abort();
411
468
  for (const state of this.states.values()) {
412
469
  if (state.pendingTimer !== void 0) clearTimeout(state.pendingTimer);
470
+ if (state.loopRetryTimer !== void 0) clearTimeout(state.loopRetryTimer);
413
471
  }
414
472
  this.states.clear();
415
473
  }
@@ -587,6 +645,10 @@ ${frame.event.data.arguments}`;
587
645
  state.toolRun = void 0;
588
646
  state.loopFired = false;
589
647
  state.loopCancelled = false;
648
+ if (state.loopRetryTimer !== void 0) {
649
+ clearTimeout(state.loopRetryTimer);
650
+ state.loopRetryTimer = void 0;
651
+ }
590
652
  this.cancelPending(sessionId, "宿主自行开启新回合");
591
653
  break;
592
654
  case "turn/end": {
@@ -596,23 +658,34 @@ ${frame.event.data.arguments}`;
596
658
  if (reason.kind === "completed") {
597
659
  state.consecutive = 0;
598
660
  state.lastFailure = void 0;
661
+ clearSendCount(sessionId);
599
662
  this.noteRecovery(sessionId, "completed");
600
663
  } else if (reason.kind === "aborted") {
601
664
  if (state.loopCancelled) {
602
665
  state.loopCancelled = false;
603
666
  state.loopFired = false;
604
- state.consecutive = 0;
605
667
  state.pendingRecoveryAt = 0;
606
668
  state.shortRun = 0;
607
669
  state.lastShortAt = 0;
608
670
  state.lastAssistantText = "";
609
671
  state.sameTextRun = 0;
610
672
  state.toolRun = void 0;
611
- state.lastAttemptAt = 0;
612
- this.schedule(sessionId, "loop:aborted");
673
+ const cooldown = this.cooldownFor(state);
674
+ const remaining = cooldown - (Date.now() - state.lastAttemptAt);
675
+ if (remaining > 0) {
676
+ if (state.loopRetryTimer !== void 0) clearTimeout(state.loopRetryTimer);
677
+ state.loopRetryTimer = setTimeout(() => {
678
+ state.loopRetryTimer = void 0;
679
+ this.schedule(sessionId, "loop:aborted");
680
+ }, remaining);
681
+ this.log(`loop 重启延迟 ${remaining}ms(冷却期) ${sessionId}`);
682
+ } else {
683
+ this.schedule(sessionId, "loop:aborted");
684
+ }
613
685
  } else {
614
686
  state.consecutive = 0;
615
687
  state.pendingRecoveryAt = 0;
688
+ clearSendCount(sessionId);
616
689
  }
617
690
  } else if (reason.kind === "blocked") {
618
691
  } else if (reason.kind === "interrupted") {
@@ -637,9 +710,10 @@ ${frame.event.data.arguments}`;
637
710
  break;
638
711
  }
639
712
  case "user/message":
640
- if (isOurEcho(state, event)) break;
713
+ if (isOurEcho(state, sessionId, event)) break;
641
714
  if (event.data.source.kind === "user") {
642
715
  state.consecutive = 0;
716
+ clearSendCount(sessionId);
643
717
  this.cancelPending(sessionId, "用户手动发送消息");
644
718
  }
645
719
  break;
@@ -811,12 +885,8 @@ ${frame.event.data.arguments}`;
811
885
  this.log(`跳过 ${sessionId}: 已有排队消息`);
812
886
  return;
813
887
  }
814
- if (!force && Date.now() - readLastSend(sessionId) < this.cooldownFor(state)) {
815
- this.log(`跳过 ${sessionId}: 其他标签页刚发送过`);
816
- return;
817
- }
818
- if (!claimSend(sessionId)) {
819
- this.log(`跳过 ${sessionId}: 其他标签页正在发送`);
888
+ if (!force && readSendCount(sessionId).count >= config.maxConsecutive) {
889
+ this.log(`跳过 ${sessionId}: 发送计数已达上限 ${config.maxConsecutive}, 等待用户介入或成功回合`);
820
890
  return;
821
891
  }
822
892
  const template = reason.startsWith("loop:") ? config.loopText : reason.includes("max-tokens") ? config.continueTextMaxTokens : config.continueText;
@@ -830,51 +900,65 @@ ${frame.event.data.arguments}`;
830
900
  }
831
901
  const text = this.buildContinueText(config, state, template, sessionTitle);
832
902
  const zone = clientTimeZone();
833
- state.lastAttemptAt = Date.now();
834
- try {
835
- const response = await this.api.sessions.prompt({
836
- sessionId,
837
- mode: "queue",
838
- content: [{ type: "text", text }],
839
- ...zone === void 0 ? {} : { clientTimeZone: zone }
840
- });
841
- if (response.result.ok) {
842
- const now = Date.now();
843
- state.consecutive += 1;
844
- state.lastAutoAt = now;
845
- state.lastSentText = text;
846
- state.pendingRecoveryAt = now;
847
- writeLastSend(sessionId, now);
848
- bumpStat({ sent: 1, ...state.lastFailure !== void 0 ? { code: state.lastFailure.code } : {} });
849
- this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
850
- if (config.notify) {
851
- notify(
852
- "dsh-auto-continue: 已自动继续",
853
- `${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`,
854
- this.notifyOptions(sessionId)
855
- );
856
- }
857
- if (state.consecutive >= config.maxConsecutive) {
858
- bumpStat({ gaveUp: 1 });
859
- this.log(`达到连续上限 ${config.maxConsecutive} 次, 停止自动继续 ${sessionId}`);
903
+ await withSendLock(sessionId, async () => {
904
+ if (this.disposed) return;
905
+ if (state.queued > 0) {
906
+ this.log(`跳过 ${sessionId}: 已有排队消息`);
907
+ return;
908
+ }
909
+ if (!force && Date.now() - readLastSend(sessionId) < this.cooldownFor(state)) {
910
+ this.log(`跳过 ${sessionId}: 其他标签页刚发送过`);
911
+ return;
912
+ }
913
+ if (!force && readSendCount(sessionId).count >= config.maxConsecutive) {
914
+ this.log(`跳过 ${sessionId}: 发送计数已达上限 ${config.maxConsecutive}, 等待用户介入或成功回合`);
915
+ return;
916
+ }
917
+ state.lastAttemptAt = Date.now();
918
+ try {
919
+ const response = await this.api.sessions.prompt({
920
+ sessionId,
921
+ mode: "queue",
922
+ content: [{ type: "text", text }],
923
+ ...zone === void 0 ? {} : { clientTimeZone: zone }
924
+ });
925
+ if (response.result.ok) {
926
+ const now = Date.now();
927
+ state.consecutive += 1;
928
+ state.lastAutoAt = now;
929
+ state.lastSentText = text;
930
+ state.pendingRecoveryAt = now;
931
+ writeLastSend(sessionId, now, text);
932
+ bumpSendCount(sessionId);
933
+ bumpStat({ sent: 1, ...state.lastFailure !== void 0 ? { code: state.lastFailure.code } : {} });
934
+ this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
860
935
  if (config.notify) {
861
936
  notify(
862
- "dsh-auto-continue: 已停止自动继续",
863
- `${sessionId}: 连续失败 ${state.consecutive} 次, 需要人工介入`,
937
+ "dsh-auto-continue: 已自动继续",
938
+ `${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`,
864
939
  this.notifyOptions(sessionId)
865
940
  );
866
941
  }
942
+ if (state.consecutive >= config.maxConsecutive) {
943
+ bumpStat({ gaveUp: 1 });
944
+ this.log(`达到连续上限 ${config.maxConsecutive} 次, 停止自动继续 ${sessionId}`);
945
+ if (config.notify) {
946
+ notify(
947
+ "dsh-auto-continue: 已停止自动继续",
948
+ `${sessionId}: 连续失败 ${state.consecutive} 次, 需要人工介入`,
949
+ this.notifyOptions(sessionId)
950
+ );
951
+ }
952
+ }
953
+ } else {
954
+ this.log(
955
+ `发送失败 ${sessionId}: ${response.result.error.code} ${response.result.error.message}`
956
+ );
867
957
  }
868
- } else {
869
- this.log(
870
- `发送失败 ${sessionId}: ${response.result.error.code} ${response.result.error.message}`
871
- );
958
+ } catch (error) {
959
+ this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
872
960
  }
873
- } catch (error) {
874
- this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
875
- } finally {
876
- releaseSend(sessionId);
877
- }
961
+ });
878
962
  }
879
963
  /**
880
964
  * 组装本次续跑消息: 模板填充 + 幂等护栏。