dsh-client-auto-continue 0.7.3 → 0.7.5

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
@@ -64,9 +64,9 @@ It watches the live event streams and reacts to:
64
64
 
65
65
  The plugin opens two extra SSE streams in the browser — `events.mux` (session events) and `events.host` (host events). The host supports multiple consumers, so this never interferes with the built-in runtime. On an interruption it waits a **grace period** (default 3 s) — if the host starts a new turn by itself (`turn/start`), the auto-continue is cancelled — then calls `sessions.prompt` in `queue` mode with the configured text.
66
66
 
67
- On page load / reconnect it also scans the most recently updated sessions: a session whose last turn ended with a non-human reason **within the scan window** (default 15 minutes), with no later `turn/start` or user message, gets resumed automatically too (e.g. the host crashed while the browser was closed).
67
+ On page load / reconnect it also scans the most recently updated sessions: a session whose last turn ended with a non-human reason **within the scan window** (default 15 minutes), with no later `turn/start` or user message, gets resumed automatically too (e.g. the host crashed while the browser was closed). Before every send the plugin also asks the host one authoritative question — is the latest session event already this exact message? If the same text is still queued (the last event is that `user/message`), it skips; once the message has been processed (the last event is a turn end or anything else), a fresh send is allowed, so legitimate consecutive resumes are never blocked.
68
68
 
69
- With the page open in several tabs, a localStorage mutex plus a shared per-session cooldown stamp guarantee exactly one tab sends — no duplicated 「继续」.
69
+ With the page open in several tabs, a cross-tab atomic send lock (Web Locks API with a mutex-stamp fallback), a shared per-session cooldown stamp, and a shared send counter that hard-caps consecutive sends at `maxConsecutive` guarantee exactly one tab sends — no duplicated 「继续」, even when several tabs watch the same session.
70
70
 
71
71
  All knobs live in the plugin's settings card — see [Configuration](#configuration).
72
72
 
@@ -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 — 43 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
@@ -64,9 +64,9 @@
64
64
 
65
65
  插件在浏览器里额外打开两条 SSE 流——`events.mux`(会话事件)与 `events.host`(宿主事件)。宿主支持多消费者, 与内置运行时互不干扰。检测到中断后先等待一个**宽限期**(默认 3 秒)——若宿主自行开启了新回合(`turn/start`), 自动继续即取消——然后以 `queue` 模式调用 `sessions.prompt` 发送配置的文本。
66
66
 
67
- 页面启动 / 重连时, 插件还会扫描最近更新的会话: 若某个会话的最后一个回合在**扫描时间窗**(默认 15 分钟)内以非人为原因结束, 且之后没有新的 `turn/start` 或用户消息, 也会被自动续跑(例如浏览器关闭期间宿主崩溃的情况)
67
+ 页面启动 / 重连时, 插件还会扫描最近更新的会话: 若某个会话的最后一个回合在**扫描时间窗**(默认 15 分钟)内以非人为原因结束, 且之后没有新的 `turn/start` 或用户消息, 也会被自动续跑(例如浏览器关闭期间宿主崩溃的情况)。每次发送前插件还会问宿主一个权威问题——会话的**最后一条事件**是否正是这条消息: 若相同文本还在排队(最后一条就是那条 `user/message`)则跳过; 一旦该消息已被处理(最后一条是回合结束或其他事件)就允许再发, 因此合法的连续续跑不会被误挡。
68
68
 
69
- 多个标签页同时打开时, 通过 localStorage 互斥锁 + 共享的每会话冷却记录保证**只有一个标签页发送**——不会重复出现两条「继续」。
69
+ 多个标签页同时打开时, 通过跨标签页原子发送锁(Web Locks API, 旧环境回退互斥戳)+ 共享的每会话冷却记录 + 以 `maxConsecutive` 为硬上限的共享发送计数, 保证**只有一个标签页发送**——即使多个标签页盯着同一会话, 也不会重复出现两条「继续」。
70
70
 
71
71
  所有参数都在插件的设置卡片中调整——见 [配置](#配置)。
72
72
 
@@ -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 — 43 个行为场景
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,69 @@ ${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
+ if (!force && await this.hostHasPendingSameText(sessionId, text)) {
918
+ this.log(`跳过 ${sessionId}: 宿主队列里已有相同文本消息在排队`);
919
+ return;
920
+ }
921
+ state.lastAttemptAt = Date.now();
922
+ try {
923
+ const response = await this.api.sessions.prompt({
924
+ sessionId,
925
+ mode: "queue",
926
+ content: [{ type: "text", text }],
927
+ ...zone === void 0 ? {} : { clientTimeZone: zone }
928
+ });
929
+ if (response.result.ok) {
930
+ const now = Date.now();
931
+ state.consecutive += 1;
932
+ state.lastAutoAt = now;
933
+ state.lastSentText = text;
934
+ state.pendingRecoveryAt = now;
935
+ writeLastSend(sessionId, now, text);
936
+ bumpSendCount(sessionId);
937
+ bumpStat({ sent: 1, ...state.lastFailure !== void 0 ? { code: state.lastFailure.code } : {} });
938
+ this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
860
939
  if (config.notify) {
861
940
  notify(
862
- "dsh-auto-continue: 已停止自动继续",
863
- `${sessionId}: 连续失败 ${state.consecutive} 次, 需要人工介入`,
941
+ "dsh-auto-continue: 已自动继续",
942
+ `${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`,
864
943
  this.notifyOptions(sessionId)
865
944
  );
866
945
  }
946
+ if (state.consecutive >= config.maxConsecutive) {
947
+ bumpStat({ gaveUp: 1 });
948
+ this.log(`达到连续上限 ${config.maxConsecutive} 次, 停止自动继续 ${sessionId}`);
949
+ if (config.notify) {
950
+ notify(
951
+ "dsh-auto-continue: 已停止自动继续",
952
+ `${sessionId}: 连续失败 ${state.consecutive} 次, 需要人工介入`,
953
+ this.notifyOptions(sessionId)
954
+ );
955
+ }
956
+ }
957
+ } else {
958
+ this.log(
959
+ `发送失败 ${sessionId}: ${response.result.error.code} ${response.result.error.message}`
960
+ );
867
961
  }
868
- } else {
869
- this.log(
870
- `发送失败 ${sessionId}: ${response.result.error.code} ${response.result.error.message}`
871
- );
962
+ } catch (error) {
963
+ this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
872
964
  }
873
- } catch (error) {
874
- this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
875
- } finally {
876
- releaseSend(sessionId);
877
- }
965
+ });
878
966
  }
879
967
  /**
880
968
  * 组装本次续跑消息: 模板填充 + 幂等护栏。
@@ -910,6 +998,25 @@ ${frame.event.data.arguments}`;
910
998
  }
911
999
  return { kind: "failed", tool: state.lastTool };
912
1000
  }
1001
+ /**
1002
+ * 宿主权威兜底: 历史里最后一条事件是否就是同一文本的 user 消息。
1003
+ * 是 = 它还在排队未被处理, 不应再叠加发送; 否(回合结束等其他事件)= 放行。
1004
+ * 查询失败时返回 false(放行, 本地防线仍在)。
1005
+ */
1006
+ async hostHasPendingSameText(sessionId, text) {
1007
+ try {
1008
+ const response = await this.api.sessions.history({ sessionId, maxMessages: 10 });
1009
+ if (!response.result.ok) return false;
1010
+ const events = response.result.value.events;
1011
+ const last = events[events.length - 1]?.event;
1012
+ if (last === void 0 || last.type !== "user/message") return false;
1013
+ if (last.data.source?.kind !== "user") return false;
1014
+ const lastText = (last.data.content ?? []).filter((part) => part.type === "text").map((part) => part.text).join("");
1015
+ return lastText === text;
1016
+ } catch {
1017
+ return false;
1018
+ }
1019
+ }
913
1020
  /** 查一次 session.list, 顺带缓存该会话的标题。 */
914
1021
  async fetchSessionInfo(sessionId) {
915
1022
  try {