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/README.md +5 -3
- package/README.zh.md +5 -3
- package/lib/client.js +188 -67
- package/lib/client.js.map +3 -3
- package/lib/index.js +2 -0
- package/lib/types/client/engine.d.ts +2 -0
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/client/settings-card.d.ts +1 -0
- package/lib/types/index.d.ts +4 -0
- package/package.json +1 -1
- package/src/client/engine.ts +223 -72
- package/src/client/locales.ts +4 -0
- package/src/client/settings-card.tsx +13 -0
- package/src/index.ts +2 -0
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ For [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh we
|
|
|
43
43
|
- **Idempotency guard** — before resuming, the plugin inspects the last tool call: if its result is unconfirmed (the turn died mid-tool, e.g. a `git push` that may have gone through), the resume message tells the model to check state first and not to rerun; if the tool is confirmed done, it says so and asks not to repeat it; a failed tool gets no guard (retrying it is the point). Both guard texts are configurable (`{tool}` / `{result}` placeholders)
|
|
44
44
|
- **Pause** — a global **Pause auto-continue** toggle in the settings card stops everything (live + scan) instantly; per-session pauses (e.g. via a notification button) suspend only one session until they expire. The **Resume now** notification button is the one explicit exception: pressing it is the user asking for exactly one send, pause or not
|
|
45
45
|
- **Notification buttons** — notifications carry **Resume now** (send immediately, ignoring cooldown, the consecutive cap and any pause) and **Pause this session 1h** actions
|
|
46
|
-
- **Loop guard** — watches **running** turns too: many short
|
|
46
|
+
- **Loop guard** — watches **running** turns too. Three signals trip the guard, which cancels the turn and restarts it with a configurable loop text ("stop repeating, try another way"): the model repeating the **exact same message** several times (any length — e.g. "Let me test variants of the regex…" ×7), many short messages inside a short time window with no tool call in between (the "Let me read…" spin), or the same tool called repeatedly with the **same arguments and the same results** (a changed argument or result counts as progress). The cancel carries an internal marker so it is never confused with a user stop — the restart only happens for guard-initiated cancels. Thresholds, the time window and the loop text are configurable
|
|
47
47
|
- **Stats panel** — the settings card shows today's auto-continue count, recoveries, failures, permanent skips, give-ups and loop breaks, broken down by error code, with a one-click reset
|
|
48
48
|
- **Browser notifications** — optional alerts when auto-continue fires, gives up, or hits a permanent error; the browser asks for permission on first use, and nothing is shown again after a denial
|
|
49
49
|
|
|
@@ -177,6 +177,7 @@ auto-continue:
|
|
|
177
177
|
loopShortChars: 40
|
|
178
178
|
loopWindowMs: 30000
|
|
179
179
|
loopShortCount: 12
|
|
180
|
+
loopRepeatText: 4
|
|
180
181
|
loopToolRepeat: 5
|
|
181
182
|
loopText: '(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)'
|
|
182
183
|
```
|
|
@@ -202,6 +203,7 @@ auto-continue:
|
|
|
202
203
|
| Short-sentence max (chars) | `40` | A model message shorter than this counts as a short sentence (spinning signal) |
|
|
203
204
|
| Short-sentence window (ms) | `30000` | Consecutive short sentences must land inside this window; normal thinking spread over time is not misjudged |
|
|
204
205
|
| Short-sentence threshold | `12` | Consecutive short sentences inside the window, with no tool call in between, trip the loop guard |
|
|
206
|
+
| Identical message count | `4` | Consecutive identical messages (any length) trip the loop guard — the strongest spinning signal |
|
|
205
207
|
| Same-tool repeat count | `5` | Consecutive calls of the same tool with identical arguments and results trip the loop guard |
|
|
206
208
|
| Loop text | `(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)` | Text sent after the loop guard restarts a turn; `{tool}` placeholder |
|
|
207
209
|
| Guard text (unconfirmed result) | `(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)` | Appended when the last tool may have partially executed; `{tool}` placeholder |
|
|
@@ -230,7 +232,7 @@ The plugin is browser-only and touches **no files, credentials, or network beyon
|
|
|
230
232
|
|
|
231
233
|
- It opens the same two read-only event streams the web UI already uses (no extra server, no third-party endpoints)
|
|
232
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)
|
|
233
|
-
- 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
|
|
234
236
|
- Browser notifications are opt-in (`notify` setting) and permission is requested on first use only
|
|
235
237
|
|
|
236
238
|
---
|
|
@@ -241,7 +243,7 @@ The plugin is browser-only and touches **no files, credentials, or network beyon
|
|
|
241
243
|
npm run typecheck # tsc --noEmit
|
|
242
244
|
npm run build # lib/client.js + lib/index.js + lib/types
|
|
243
245
|
npm run watch # rebuild on change; host HMR hot-reloads without a page refresh
|
|
244
|
-
npm run test # node tests/simulate.mjs —
|
|
246
|
+
npm run test # node tests/simulate.mjs — 40 behavioral scenarios
|
|
245
247
|
```
|
|
246
248
|
|
|
247
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
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
- **幂等护栏** — 续跑前检查上一步工具调用: 结果未确认(回合在工具执行中途夭折, 如 `git push` 可能已经推上去了)时, 续跑消息会提示模型先确认状态、不要重复执行; 工具已确认成功时说明已完成、请勿重复; 工具失败则不加护栏(重试本来就是目的)。两段护栏文本都可配置(支持 `{tool}` / `{result}` 占位符)
|
|
44
44
|
- **暂停** — 设置卡片里的全局 **暂停自动继续** 开关可立即停掉一切(实时 + 扫描); 会话级暂停(如通过通知按钮)只挂起单个会话, 到期自动恢复。唯一的显式例外是通知里的 **立即续跑** 按钮——按下它等于用户明确要求发送这一次, 不受暂停限制
|
|
45
45
|
- **通知按钮** — 通知带 **立即续跑**(无视冷却、连续上限与暂停, 马上发送)和 **暂停该会话 1 小时** 按钮
|
|
46
|
-
- **循环守卫** —
|
|
46
|
+
- **循环守卫** — 连**运行中的回合**也盯着, 三个信号都会触发守卫(取消当前回合并用可配置的循环提示文本重启, 「停止重复, 换一种方式」): 模型**连续输出完全相同的消息**(不限长度, 如 "Let me test variants of the regex…" 连续 7 遍)、短时间内连续多条短句且期间无工具调用(典型的「Let me read…」空转)、或同一工具被连续反复调用且**参数与结果都相同**(参数或结果有变化视为有进展)。取消带有内部来源标记, 绝不会与用户手动停止混淆——只有守卫发起的取消才会重启。阈值、时间窗与提示文本都可配置
|
|
47
47
|
- **统计面板** — 设置卡片展示今日自动继续次数、恢复成功、继续后失败、永久性跳过、达上限停止、循环打断, 按错误码统计, 可一键清零
|
|
48
48
|
- **浏览器通知** — 可选: 自动继续成功 / 放弃 / 遇到永久性错误时弹出提醒; 首次使用时请求权限, 被拒绝后不再打扰
|
|
49
49
|
|
|
@@ -177,6 +177,7 @@ auto-continue:
|
|
|
177
177
|
loopShortChars: 40
|
|
178
178
|
loopWindowMs: 30000
|
|
179
179
|
loopShortCount: 12
|
|
180
|
+
loopRepeatText: 4
|
|
180
181
|
loopToolRepeat: 5
|
|
181
182
|
loopText: '(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)'
|
|
182
183
|
```
|
|
@@ -202,6 +203,7 @@ auto-continue:
|
|
|
202
203
|
| 短句长度上限 (字符) | `40` | 模型消息文本短于该值计为一条短句(空转信号) |
|
|
203
204
|
| 短句时间窗 (ms) | `30000` | 连续短句必须落在这个时间窗内; 正常思考的短文本散布在长时间里不会被误判 |
|
|
204
205
|
| 连续短句阈值 | `12` | 时间窗内连续多少条短句且期间无工具调用时判定空转循环 |
|
|
206
|
+
| 相同消息重复次数 | `4` | 连续输出多少条完全相同的消息时判定空转(不限长度, 最强信号) |
|
|
205
207
|
| 同工具重复次数 | `5` | 同工具+同参数+同结果的连续调用多少次时判定死循环 |
|
|
206
208
|
| 循环提示文本 | `(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)` | 打断后重启回合时发送的文本; 支持 {tool} 占位符 |
|
|
207
209
|
| 结果未确认时的护栏文本 | `(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)` | 上一步工具可能已部分执行时附加; 支持 {tool} 占位符 |
|
|
@@ -230,7 +232,7 @@ auto-continue:
|
|
|
230
232
|
|
|
231
233
|
- 只复用 webui 本身就在用的两条只读事件流(无额外服务、无第三方端点)
|
|
232
234
|
- 引擎**唯一会自动执行的写入**是 `sessions.prompt`——与点「发送」按钮完全相同的调用, 内容为你配置的文本(设置卡片里保存配置会通过常规设置 API 写入 `~/.dsh/settings.yaml` 的 `auto-continue` 段落, 与任何其他设置一样)
|
|
233
|
-
- 浏览器存储仅限于少量 `localStorage` 键:
|
|
235
|
+
- 浏览器存储仅限于少量 `localStorage` 键: 跨标签页发送锁与发送计数(多标签页同时打开时也绝不重复刷屏的硬上限)、会话级暂停、每日统计计数
|
|
234
236
|
- 浏览器通知是可选开启的(`notify` 设置), 仅在首次使用时请求一次权限
|
|
235
237
|
|
|
236
238
|
---
|
|
@@ -241,7 +243,7 @@ auto-continue:
|
|
|
241
243
|
npm run typecheck # tsc --noEmit
|
|
242
244
|
npm run build # lib/client.js + lib/index.js + lib/types
|
|
243
245
|
npm run watch # 监听变更自动重建; 宿主 HMR 免刷新热重载
|
|
244
|
-
npm run test # node tests/simulate.mjs —
|
|
246
|
+
npm run test # node tests/simulate.mjs — 40 个行为场景
|
|
245
247
|
```
|
|
246
248
|
|
|
247
249
|
`npm run watch` 运行时, profile 的 client-hmr 行每 500ms 轮询 `lib/client.js` 并在浏览器中热重载插件——改代码无需重启服务。
|
package/lib/client.js
CHANGED
|
@@ -63,6 +63,7 @@ var DEFAULT_CONFIG = {
|
|
|
63
63
|
loopShortChars: 40,
|
|
64
64
|
loopWindowMs: 3e4,
|
|
65
65
|
loopShortCount: 12,
|
|
66
|
+
loopRepeatText: 4,
|
|
66
67
|
loopToolRepeat: 5,
|
|
67
68
|
loopText: "(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)"
|
|
68
69
|
};
|
|
@@ -102,6 +103,7 @@ function resolveConfig(section) {
|
|
|
102
103
|
loopShortChars: Math.max(1, numberOr(value.loopShortChars, DEFAULT_CONFIG.loopShortChars)),
|
|
103
104
|
loopWindowMs: Math.max(1e3, numberOr(value.loopWindowMs, DEFAULT_CONFIG.loopWindowMs)),
|
|
104
105
|
loopShortCount: Math.max(2, numberOr(value.loopShortCount, DEFAULT_CONFIG.loopShortCount)),
|
|
106
|
+
loopRepeatText: Math.max(2, numberOr(value.loopRepeatText, DEFAULT_CONFIG.loopRepeatText)),
|
|
105
107
|
loopToolRepeat: Math.max(2, numberOr(value.loopToolRepeat, DEFAULT_CONFIG.loopToolRepeat)),
|
|
106
108
|
loopText: typeof value.loopText === "string" && value.loopText.trim() !== "" ? value.loopText : DEFAULT_CONFIG.loopText
|
|
107
109
|
};
|
|
@@ -202,31 +204,85 @@ function clientTimeZone() {
|
|
|
202
204
|
var lockPrefix = "dsh-auto-continue:";
|
|
203
205
|
var lockKey = (sessionId) => `${lockPrefix}lock:${sessionId}`;
|
|
204
206
|
var stampKey = (sessionId) => `${lockPrefix}last:${sessionId}`;
|
|
205
|
-
|
|
207
|
+
var countKey = (sessionId) => `${lockPrefix}count:${sessionId}`;
|
|
208
|
+
function readLastSent(sessionId) {
|
|
206
209
|
try {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
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: "" };
|
|
210
217
|
} catch {
|
|
211
|
-
return
|
|
218
|
+
return { at: 0, text: "" };
|
|
212
219
|
}
|
|
213
220
|
}
|
|
214
|
-
function
|
|
221
|
+
function readLastSend(sessionId) {
|
|
222
|
+
return readLastSent(sessionId).at;
|
|
223
|
+
}
|
|
224
|
+
function writeLastSend(sessionId, at, text) {
|
|
215
225
|
try {
|
|
216
|
-
localStorage.
|
|
226
|
+
localStorage.setItem(stampKey(sessionId), JSON.stringify({ at, text }));
|
|
217
227
|
} catch {
|
|
218
228
|
}
|
|
219
229
|
}
|
|
220
|
-
|
|
230
|
+
var SEND_COUNT_WINDOW_MS = 10 * 60 * 1e3;
|
|
231
|
+
function readSendCount(sessionId) {
|
|
221
232
|
try {
|
|
222
|
-
|
|
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
|
+
);
|
|
223
252
|
} catch {
|
|
224
|
-
return 0;
|
|
225
253
|
}
|
|
226
254
|
}
|
|
227
|
-
function
|
|
255
|
+
function clearSendCount(sessionId) {
|
|
228
256
|
try {
|
|
229
|
-
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));
|
|
230
286
|
} catch {
|
|
231
287
|
}
|
|
232
288
|
}
|
|
@@ -334,19 +390,24 @@ var freshState = () => ({
|
|
|
334
390
|
pendingRecoveryAt: 0,
|
|
335
391
|
shortRun: 0,
|
|
336
392
|
lastShortAt: 0,
|
|
393
|
+
lastAssistantText: "",
|
|
394
|
+
sameTextRun: 0,
|
|
337
395
|
toolRun: void 0,
|
|
338
396
|
loopFired: false,
|
|
339
|
-
loopCancelled: false
|
|
397
|
+
loopCancelled: false,
|
|
398
|
+
loopRetryTimer: void 0
|
|
340
399
|
});
|
|
341
400
|
var RECOVERY_WINDOW_MS = 10 * 60 * 1e3;
|
|
342
|
-
|
|
401
|
+
var ECHO_WINDOW_MS = 10 * 60 * 1e3;
|
|
402
|
+
function isOurEcho(state, sessionId, event) {
|
|
343
403
|
if (event.type !== "user/message") return false;
|
|
344
404
|
const message = event.data;
|
|
345
405
|
if (message.source.kind !== "user") return false;
|
|
346
|
-
|
|
347
|
-
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;
|
|
348
409
|
const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("");
|
|
349
|
-
return text ===
|
|
410
|
+
return text === last.text;
|
|
350
411
|
}
|
|
351
412
|
async function pumpStream(open, onFrame, onReconnect, getBackoff, log, signal) {
|
|
352
413
|
let backoff = getBackoff();
|
|
@@ -406,6 +467,7 @@ var AutoContinueRunner = class {
|
|
|
406
467
|
this.hostAbort.abort();
|
|
407
468
|
for (const state of this.states.values()) {
|
|
408
469
|
if (state.pendingTimer !== void 0) clearTimeout(state.pendingTimer);
|
|
470
|
+
if (state.loopRetryTimer !== void 0) clearTimeout(state.loopRetryTimer);
|
|
409
471
|
}
|
|
410
472
|
this.states.clear();
|
|
411
473
|
}
|
|
@@ -506,7 +568,14 @@ ${frame.event.data.arguments}`;
|
|
|
506
568
|
onAssistantMessage(sessionId, state, event) {
|
|
507
569
|
if (!this.getConfig().loopGuard) return;
|
|
508
570
|
const text = this.assistantText(event);
|
|
509
|
-
|
|
571
|
+
const trimmed = text.trim();
|
|
572
|
+
if (trimmed !== "" && trimmed === state.lastAssistantText) {
|
|
573
|
+
state.sameTextRun += 1;
|
|
574
|
+
} else {
|
|
575
|
+
state.lastAssistantText = trimmed;
|
|
576
|
+
state.sameTextRun = 1;
|
|
577
|
+
}
|
|
578
|
+
if (trimmed.length < this.getConfig().loopShortChars) {
|
|
510
579
|
const now = Date.now();
|
|
511
580
|
if (now - state.lastShortAt > this.getConfig().loopWindowMs) {
|
|
512
581
|
state.shortRun = 0;
|
|
@@ -525,7 +594,10 @@ ${frame.event.data.arguments}`;
|
|
|
525
594
|
if (state.loopFired) return;
|
|
526
595
|
if (!state.running) return;
|
|
527
596
|
const config = this.getConfig();
|
|
528
|
-
if (state.
|
|
597
|
+
if (state.sameTextRun >= config.loopRepeatText) {
|
|
598
|
+
this.log(`检测到空转循环 ${sessionId}: 连续 ${state.sameTextRun} 条相同消息`);
|
|
599
|
+
void this.interruptLoop(sessionId, state);
|
|
600
|
+
} else if (state.shortRun >= config.loopShortCount) {
|
|
529
601
|
this.log(`检测到空转循环 ${sessionId}: 连续 ${state.shortRun} 条短句且无工具调用`);
|
|
530
602
|
void this.interruptLoop(sessionId, state);
|
|
531
603
|
} else if (state.toolRun !== void 0 && state.toolRun.count >= config.loopToolRepeat) {
|
|
@@ -568,9 +640,15 @@ ${frame.event.data.arguments}`;
|
|
|
568
640
|
state.lastToolResult = void 0;
|
|
569
641
|
state.shortRun = 0;
|
|
570
642
|
state.lastShortAt = 0;
|
|
643
|
+
state.lastAssistantText = "";
|
|
644
|
+
state.sameTextRun = 0;
|
|
571
645
|
state.toolRun = void 0;
|
|
572
646
|
state.loopFired = false;
|
|
573
647
|
state.loopCancelled = false;
|
|
648
|
+
if (state.loopRetryTimer !== void 0) {
|
|
649
|
+
clearTimeout(state.loopRetryTimer);
|
|
650
|
+
state.loopRetryTimer = void 0;
|
|
651
|
+
}
|
|
574
652
|
this.cancelPending(sessionId, "宿主自行开启新回合");
|
|
575
653
|
break;
|
|
576
654
|
case "turn/end": {
|
|
@@ -580,21 +658,34 @@ ${frame.event.data.arguments}`;
|
|
|
580
658
|
if (reason.kind === "completed") {
|
|
581
659
|
state.consecutive = 0;
|
|
582
660
|
state.lastFailure = void 0;
|
|
661
|
+
clearSendCount(sessionId);
|
|
583
662
|
this.noteRecovery(sessionId, "completed");
|
|
584
663
|
} else if (reason.kind === "aborted") {
|
|
585
664
|
if (state.loopCancelled) {
|
|
586
665
|
state.loopCancelled = false;
|
|
587
666
|
state.loopFired = false;
|
|
588
|
-
state.consecutive = 0;
|
|
589
667
|
state.pendingRecoveryAt = 0;
|
|
590
668
|
state.shortRun = 0;
|
|
591
669
|
state.lastShortAt = 0;
|
|
670
|
+
state.lastAssistantText = "";
|
|
671
|
+
state.sameTextRun = 0;
|
|
592
672
|
state.toolRun = void 0;
|
|
593
|
-
|
|
594
|
-
|
|
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
|
+
}
|
|
595
685
|
} else {
|
|
596
686
|
state.consecutive = 0;
|
|
597
687
|
state.pendingRecoveryAt = 0;
|
|
688
|
+
clearSendCount(sessionId);
|
|
598
689
|
}
|
|
599
690
|
} else if (reason.kind === "blocked") {
|
|
600
691
|
} else if (reason.kind === "interrupted") {
|
|
@@ -619,9 +710,10 @@ ${frame.event.data.arguments}`;
|
|
|
619
710
|
break;
|
|
620
711
|
}
|
|
621
712
|
case "user/message":
|
|
622
|
-
if (isOurEcho(state, event)) break;
|
|
713
|
+
if (isOurEcho(state, sessionId, event)) break;
|
|
623
714
|
if (event.data.source.kind === "user") {
|
|
624
715
|
state.consecutive = 0;
|
|
716
|
+
clearSendCount(sessionId);
|
|
625
717
|
this.cancelPending(sessionId, "用户手动发送消息");
|
|
626
718
|
}
|
|
627
719
|
break;
|
|
@@ -793,12 +885,8 @@ ${frame.event.data.arguments}`;
|
|
|
793
885
|
this.log(`跳过 ${sessionId}: 已有排队消息`);
|
|
794
886
|
return;
|
|
795
887
|
}
|
|
796
|
-
if (!force &&
|
|
797
|
-
this.log(`跳过 ${sessionId}:
|
|
798
|
-
return;
|
|
799
|
-
}
|
|
800
|
-
if (!claimSend(sessionId)) {
|
|
801
|
-
this.log(`跳过 ${sessionId}: 其他标签页正在发送`);
|
|
888
|
+
if (!force && readSendCount(sessionId).count >= config.maxConsecutive) {
|
|
889
|
+
this.log(`跳过 ${sessionId}: 发送计数已达上限 ${config.maxConsecutive}, 等待用户介入或成功回合`);
|
|
802
890
|
return;
|
|
803
891
|
}
|
|
804
892
|
const template = reason.startsWith("loop:") ? config.loopText : reason.includes("max-tokens") ? config.continueTextMaxTokens : config.continueText;
|
|
@@ -812,51 +900,65 @@ ${frame.event.data.arguments}`;
|
|
|
812
900
|
}
|
|
813
901
|
const text = this.buildContinueText(config, state, template, sessionTitle);
|
|
814
902
|
const zone = clientTimeZone();
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
sessionId
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
this.
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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} 次连续`);
|
|
842
935
|
if (config.notify) {
|
|
843
936
|
notify(
|
|
844
|
-
"dsh-auto-continue:
|
|
845
|
-
`${sessionId}:
|
|
937
|
+
"dsh-auto-continue: 已自动继续",
|
|
938
|
+
`${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`,
|
|
846
939
|
this.notifyOptions(sessionId)
|
|
847
940
|
);
|
|
848
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
|
+
);
|
|
849
957
|
}
|
|
850
|
-
}
|
|
851
|
-
this.log(
|
|
852
|
-
`发送失败 ${sessionId}: ${response.result.error.code} ${response.result.error.message}`
|
|
853
|
-
);
|
|
958
|
+
} catch (error) {
|
|
959
|
+
this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
854
960
|
}
|
|
855
|
-
}
|
|
856
|
-
this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
857
|
-
} finally {
|
|
858
|
-
releaseSend(sessionId);
|
|
859
|
-
}
|
|
961
|
+
});
|
|
860
962
|
}
|
|
861
963
|
/**
|
|
862
964
|
* 组装本次续跑消息: 模板填充 + 幂等护栏。
|
|
@@ -1082,6 +1184,8 @@ var zh = {
|
|
|
1082
1184
|
"field.loopWindowMsHint": "连续短句必须落在这个时间窗内; 正常思考的短文本散布在长时间里不会被误判。",
|
|
1083
1185
|
"field.loopShortCount": "连续短句阈值",
|
|
1084
1186
|
"field.loopShortCountHint": "时间窗内连续多少条短句且期间无工具调用时判定空转循环。",
|
|
1187
|
+
"field.loopRepeatText": "相同消息重复次数",
|
|
1188
|
+
"field.loopRepeatTextHint": "连续输出多少条完全相同的消息时判定空转(最强信号, 不限长度, 如模型反复说同一句话)。",
|
|
1085
1189
|
"field.loopToolRepeat": "同工具重复次数",
|
|
1086
1190
|
"field.loopToolRepeatHint": "同工具+同参数+同结果的连续调用多少次时判定死循环; 参数或结果有变化视为有进展。",
|
|
1087
1191
|
"field.loopText": "循环提示文本",
|
|
@@ -1165,6 +1269,8 @@ var en = {
|
|
|
1165
1269
|
"field.loopWindowMsHint": "Consecutive short sentences must land inside this window; normal thinking spread over time is not misjudged.",
|
|
1166
1270
|
"field.loopShortCount": "Short-sentence threshold",
|
|
1167
1271
|
"field.loopShortCountHint": "How many consecutive short sentences inside the window, with no tool call, trip the loop guard.",
|
|
1272
|
+
"field.loopRepeatText": "Identical message count",
|
|
1273
|
+
"field.loopRepeatTextHint": "How many consecutive identical messages trip the guard (strongest signal, any length, e.g. the model repeating the same line).",
|
|
1168
1274
|
"field.loopToolRepeat": "Same-tool repeat count",
|
|
1169
1275
|
"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.",
|
|
1170
1276
|
"field.loopText": "Loop text",
|
|
@@ -1578,6 +1684,7 @@ var AutoContinueSettingsCardController = class {
|
|
|
1578
1684
|
numberField("loopShortChars", 1),
|
|
1579
1685
|
numberField("loopWindowMs", 1e3),
|
|
1580
1686
|
numberField("loopShortCount", 2),
|
|
1687
|
+
numberField("loopRepeatText", 2),
|
|
1581
1688
|
numberField("loopToolRepeat", 2),
|
|
1582
1689
|
textField("loopText")
|
|
1583
1690
|
]);
|
|
@@ -1609,6 +1716,7 @@ var AutoContinueSettingsCardController = class {
|
|
|
1609
1716
|
loopShortChars: this.form.field("loopShortChars"),
|
|
1610
1717
|
loopWindowMs: this.form.field("loopWindowMs"),
|
|
1611
1718
|
loopShortCount: this.form.field("loopShortCount"),
|
|
1719
|
+
loopRepeatText: this.form.field("loopRepeatText"),
|
|
1612
1720
|
loopToolRepeat: this.form.field("loopToolRepeat"),
|
|
1613
1721
|
loopText: this.form.field("loopText")
|
|
1614
1722
|
};
|
|
@@ -2150,6 +2258,19 @@ function AutoContinueSettingsCard(props) {
|
|
|
2150
2258
|
onReset: () => props.resetField("loopToolRepeat")
|
|
2151
2259
|
}
|
|
2152
2260
|
),
|
|
2261
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
2262
|
+
ValueField,
|
|
2263
|
+
{
|
|
2264
|
+
id: "auto-continue-loop-repeat-text",
|
|
2265
|
+
label: t("field.loopRepeatText"),
|
|
2266
|
+
hint: t("field.loopRepeatTextHint"),
|
|
2267
|
+
numeric: true,
|
|
2268
|
+
...shared,
|
|
2269
|
+
...state.loopRepeatText,
|
|
2270
|
+
onEdit: (text) => props.edit("loopRepeatText", text),
|
|
2271
|
+
onReset: () => props.resetField("loopRepeatText")
|
|
2272
|
+
}
|
|
2273
|
+
),
|
|
2153
2274
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
2154
2275
|
ValueField,
|
|
2155
2276
|
{
|