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 +2 -2
- package/README.zh.md +2 -2
- package/lib/client.js +149 -65
- package/lib/client.js.map +3 -3
- package/package.json +1 -1
- package/src/client/engine.ts +194 -70
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
|
|
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 —
|
|
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 —
|
|
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
|
-
|
|
207
|
+
var countKey = (sessionId) => `${lockPrefix}count:${sessionId}`;
|
|
208
|
+
function readLastSent(sessionId) {
|
|
208
209
|
try {
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
218
|
+
return { at: 0, text: "" };
|
|
214
219
|
}
|
|
215
220
|
}
|
|
216
|
-
function
|
|
221
|
+
function readLastSend(sessionId) {
|
|
222
|
+
return readLastSent(sessionId).at;
|
|
223
|
+
}
|
|
224
|
+
function writeLastSend(sessionId, at, text) {
|
|
217
225
|
try {
|
|
218
|
-
localStorage.
|
|
226
|
+
localStorage.setItem(stampKey(sessionId), JSON.stringify({ at, text }));
|
|
219
227
|
} catch {
|
|
220
228
|
}
|
|
221
229
|
}
|
|
222
|
-
|
|
230
|
+
var SEND_COUNT_WINDOW_MS = 10 * 60 * 1e3;
|
|
231
|
+
function readSendCount(sessionId) {
|
|
223
232
|
try {
|
|
224
|
-
|
|
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
|
|
255
|
+
function clearSendCount(sessionId) {
|
|
230
256
|
try {
|
|
231
|
-
localStorage.
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
if (
|
|
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 ===
|
|
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
|
-
|
|
612
|
-
|
|
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 &&
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
sessionId
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
this.
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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}:
|
|
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
|
-
}
|
|
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
|
-
}
|
|
874
|
-
this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
875
|
-
} finally {
|
|
876
|
-
releaseSend(sessionId);
|
|
877
|
-
}
|
|
961
|
+
});
|
|
878
962
|
}
|
|
879
963
|
/**
|
|
880
964
|
* 组装本次续跑消息: 模板填充 + 幂等护栏。
|