dsh-client-auto-continue 0.2.2 → 0.3.1

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/lib/client.js CHANGED
@@ -42,7 +42,11 @@ var DEFAULT_CONFIG = {
42
42
  freshMs: 15 * 60 * 1e3,
43
43
  reconnectScanDelayMs: 5e3,
44
44
  reconnectBackoffMs: 3e3,
45
- verbose: true
45
+ verbose: true,
46
+ classify: true,
47
+ backoffFactor: 2,
48
+ backoffMaxMs: 3e5,
49
+ notify: false
46
50
  };
47
51
  function numberOr(value, fallback) {
48
52
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
@@ -63,12 +67,46 @@ function resolveConfig(section) {
63
67
  freshMs: numberOr(value.freshMs, DEFAULT_CONFIG.freshMs),
64
68
  reconnectScanDelayMs: numberOr(value.reconnectScanDelayMs, DEFAULT_CONFIG.reconnectScanDelayMs),
65
69
  reconnectBackoffMs: numberOr(value.reconnectBackoffMs, DEFAULT_CONFIG.reconnectBackoffMs),
66
- verbose: booleanOr(value.verbose, DEFAULT_CONFIG.verbose)
70
+ verbose: booleanOr(value.verbose, DEFAULT_CONFIG.verbose),
71
+ classify: booleanOr(value.classify, DEFAULT_CONFIG.classify),
72
+ backoffFactor: Math.max(1, numberOr(value.backoffFactor, DEFAULT_CONFIG.backoffFactor)),
73
+ backoffMaxMs: numberOr(value.backoffMaxMs, DEFAULT_CONFIG.backoffMaxMs),
74
+ notify: booleanOr(value.notify, DEFAULT_CONFIG.notify)
67
75
  };
68
76
  }
69
77
  function isNonHumanReason(kind) {
70
78
  return kind === "error" || kind === "interrupted" || kind === "max-tokens";
71
79
  }
80
+ function isTransientFailure(failure) {
81
+ const haystack = `${failure.code} ${failure.message}`.toLowerCase();
82
+ const status = failure.status;
83
+ if (status !== void 0 && (status === 401 || status === 403)) return false;
84
+ const permanent = /auth|unauthor|forbidden|credential|api[_-]?key|permission/i.test(haystack) || /insufficient.*(balance|quota)|billing|payment|quota.*exceeded.*(?!retry)/i.test(haystack) || /model.*not[_-]?found|unknown[_-]?model|model[_-]?not[_-]?found|not.*support.*model/i.test(haystack) || /context.*(length|limit|overflow|exceed)|token.*limit|max.*context/i.test(haystack) || /invalid[_-]?request|bad[_-]?request/i.test(haystack);
85
+ return !permanent;
86
+ }
87
+ function notify(title, body) {
88
+ try {
89
+ const N = globalThis.Notification;
90
+ if (typeof N === "undefined") return;
91
+ const permission = N.permission;
92
+ if (permission === "granted") {
93
+ new N(title, { body });
94
+ } else if (permission === "default") {
95
+ void N.requestPermission?.().then((result) => {
96
+ if (result === "granted") new N(title, { body });
97
+ }).catch(() => {
98
+ });
99
+ }
100
+ } catch {
101
+ }
102
+ }
103
+ function fillTemplate(template, facts, tool, turn) {
104
+ return template.replace(/\{code\}/g, facts?.code ?? "").replace(/\{message\}/g, facts?.message ?? "").replace(/\{status\}/g, facts?.status !== void 0 ? String(facts.status) : "").replace(/\{tool\}/g, tool ?? "").replace(/\{turn\}/g, turn !== void 0 ? String(turn) : "");
105
+ }
106
+ function effectiveCooldown(consecutive, base, factor, max) {
107
+ const multiplier = Math.pow(factor, consecutive);
108
+ return Math.min(Math.max(base, base * multiplier), Math.max(base, max));
109
+ }
72
110
  function sleep(ms) {
73
111
  return new Promise((resolve) => setTimeout(resolve, ms));
74
112
  }
@@ -118,7 +156,10 @@ var freshState = () => ({
118
156
  pendingTimer: void 0,
119
157
  running: void 0,
120
158
  queued: 0,
121
- subagent: false
159
+ subagent: false,
160
+ lastFailure: void 0,
161
+ lastTool: void 0,
162
+ lastTurn: void 0
122
163
  });
123
164
  function isOurEcho(state, event) {
124
165
  if (event.type !== "user/message") return false;
@@ -220,6 +261,10 @@ var AutoContinueRunner = class {
220
261
  onMuxFrame(frame) {
221
262
  switch (frame.type) {
222
263
  case "session/event":
264
+ if (frame.event.type === "tool/call") {
265
+ const name = frame.event.data.name;
266
+ if (typeof name === "string") this.state(frame.sessionId).lastTool = name;
267
+ }
223
268
  this.onSessionEvent(frame.sessionId, frame.event);
224
269
  break;
225
270
  case "session/queue":
@@ -246,9 +291,19 @@ var AutoContinueRunner = class {
246
291
  const reason = event.data.reason;
247
292
  if (reason.kind === "completed") {
248
293
  state.consecutive = 0;
294
+ state.lastFailure = void 0;
249
295
  } else if (reason.kind === "aborted") {
250
296
  state.consecutive = 0;
251
297
  } else if (reason.kind === "blocked") {
298
+ } else if (reason.kind === "error") {
299
+ const error = reason.error;
300
+ state.lastFailure = {
301
+ code: typeof error.code === "string" ? error.code : "UNKNOWN",
302
+ message: typeof error.message === "string" ? error.message : String(error),
303
+ ...typeof error.status === "number" ? { status: error.status } : {}
304
+ };
305
+ state.lastTurn = event.data.turn;
306
+ this.onTurnFailure(sessionId, "turn/end:error", state.lastFailure);
252
307
  } else if (isNonHumanReason(reason.kind)) {
253
308
  this.schedule(sessionId, `turn/end:${reason.kind}`);
254
309
  }
@@ -289,12 +344,35 @@ var AutoContinueRunner = class {
289
344
  }
290
345
  }
291
346
  // ---------- 调度 ----------
347
+ /** 回合失败入口: 先做错误分类, 永久性失败跳过并通知, 临时性失败走正常调度。 */
348
+ onTurnFailure(sessionId, reason, failure) {
349
+ const config = this.getConfig();
350
+ if (config.classify && !isTransientFailure(failure)) {
351
+ const summary = `${failure.code}${failure.status !== void 0 ? ` (HTTP ${failure.status})` : ""}`;
352
+ this.log(`跳过 ${sessionId}(${reason}): 永久性失败 ${summary} — ${failure.message}`);
353
+ if (config.notify) {
354
+ notify("dsh-auto-continue: 未自动继续", `${sessionId}: 永久性错误 ${summary},需要人工处理`);
355
+ }
356
+ return;
357
+ }
358
+ this.schedule(sessionId, reason);
359
+ }
360
+ /** 本会话当前生效的冷却间隔(自适应退避)。 */
361
+ cooldownFor(state) {
362
+ const config = this.getConfig();
363
+ return effectiveCooldown(
364
+ state.consecutive,
365
+ config.cooldownMs,
366
+ config.backoffFactor,
367
+ config.backoffMaxMs
368
+ );
369
+ }
292
370
  schedule(sessionId, reason) {
293
371
  const state = this.state(sessionId);
294
372
  const config = this.getConfig();
295
373
  if (state.subagent) return;
296
374
  if (state.pendingTimer !== void 0) return;
297
- if (Date.now() - state.lastAttemptAt < config.cooldownMs) return;
375
+ if (Date.now() - state.lastAttemptAt < this.cooldownFor(state)) return;
298
376
  if (state.consecutive >= config.maxConsecutive) {
299
377
  this.log(
300
378
  `跳过 ${sessionId}(${reason}): 已连续自动继续 ${state.consecutive} 次, 等待用户介入或成功回合`
@@ -337,7 +415,7 @@ var AutoContinueRunner = class {
337
415
  this.log(`跳过 ${sessionId}: 已有排队消息`);
338
416
  return;
339
417
  }
340
- if (Date.now() - readLastSend(sessionId) < config.cooldownMs) {
418
+ if (Date.now() - readLastSend(sessionId) < this.cooldownFor(state)) {
341
419
  this.log(`跳过 ${sessionId}: 其他标签页刚发送过`);
342
420
  return;
343
421
  }
@@ -345,7 +423,7 @@ var AutoContinueRunner = class {
345
423
  this.log(`跳过 ${sessionId}: 其他标签页正在发送`);
346
424
  return;
347
425
  }
348
- const text = config.continueText;
426
+ const text = fillTemplate(config.continueText, state.lastFailure, state.lastTool, state.lastTurn);
349
427
  const zone = clientTimeZone();
350
428
  state.lastAttemptAt = Date.now();
351
429
  try {
@@ -362,6 +440,15 @@ var AutoContinueRunner = class {
362
440
  state.lastSentText = text;
363
441
  writeLastSend(sessionId, now);
364
442
  this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
443
+ if (config.notify) {
444
+ notify("dsh-auto-continue: 已自动继续", `${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`);
445
+ }
446
+ if (state.consecutive >= config.maxConsecutive) {
447
+ this.log(`达到连续上限 ${config.maxConsecutive} 次, 停止自动继续 ${sessionId}`);
448
+ if (config.notify) {
449
+ notify("dsh-auto-continue: 已停止自动继续", `${sessionId}: 连续失败 ${state.consecutive} 次, 需要人工介入`);
450
+ }
451
+ }
365
452
  } else {
366
453
  this.log(
367
454
  `发送失败 ${sessionId}: ${response.result.error.code} ${response.result.error.message}`
@@ -429,7 +516,7 @@ var AutoContinueRunner = class {
429
516
  const state = this.state(summary.sessionId);
430
517
  if (state.pendingTimer !== void 0) continue;
431
518
  if (state.consecutive >= config.maxConsecutive) continue;
432
- if (now - state.lastAttemptAt < config.cooldownMs) continue;
519
+ if (now - state.lastAttemptAt < this.cooldownFor(state)) continue;
433
520
  let events;
434
521
  try {
435
522
  const page = await this.api.sessions.history({
@@ -493,6 +580,14 @@ var zh = {
493
580
  "field.reconnectBackoffMsHint": "事件流断开后的重连间隔。",
494
581
  "field.verbose": "详细日志",
495
582
  "field.verboseHint": "在浏览器控制台输出 [auto-continue] 日志。",
583
+ "field.classify": "错误分类",
584
+ "field.classifyHint": "仅自动恢复临时性错误(网络/超时/5xx 等); 认证/余额/模型不存在等永久性错误跳过并通知。",
585
+ "field.backoffFactor": "退避系数",
586
+ "field.backoffFactorHint": "连续失败时冷却间隔的倍率(如 2 表示 20s→40s→80s 递增)。",
587
+ "field.backoffMaxMs": "最大退避间隔 (ms)",
588
+ "field.backoffMaxMsHint": "自适应退避的上限, 防止等待过久。",
589
+ "field.notify": "浏览器通知",
590
+ "field.notifyHint": "自动继续成功/放弃/遇到永久性错误时弹出浏览器通知。",
496
591
  "chrome.collapse": "收起设置",
497
592
  "chrome.expand": "展开设置",
498
593
  "chrome.unsaved": "未保存",
@@ -531,6 +626,14 @@ var en = {
531
626
  "field.reconnectBackoffMsHint": "Interval between event-stream reconnect attempts.",
532
627
  "field.verbose": "Verbose logs",
533
628
  "field.verboseHint": "Log [auto-continue] lines to the browser console.",
629
+ "field.classify": "Classify errors",
630
+ "field.classifyHint": "Auto-resume transient failures only (network/timeout/5xx…); auth, balance and model errors are skipped and notified.",
631
+ "field.backoffFactor": "Backoff factor",
632
+ "field.backoffFactorHint": "Cooldown multiplier per consecutive failure (2 = 20s→40s→80s…).",
633
+ "field.backoffMaxMs": "Max backoff (ms)",
634
+ "field.backoffMaxMsHint": "Cap on the adaptive backoff interval.",
635
+ "field.notify": "Browser notifications",
636
+ "field.notifyHint": "Notify when auto-continue fires, gives up, or hits a permanent error.",
534
637
  "chrome.collapse": "Hide settings",
535
638
  "chrome.expand": "Show settings",
536
639
  "chrome.unsaved": "Unsaved",
@@ -892,7 +995,11 @@ var AutoContinueSettingsCardController = class {
892
995
  numberField("freshMs", 0),
893
996
  numberField("reconnectScanDelayMs", 0),
894
997
  numberField("reconnectBackoffMs", 0),
895
- booleanField("verbose")
998
+ booleanField("verbose"),
999
+ booleanField("classify"),
1000
+ numberField("backoffFactor", 1),
1001
+ numberField("backoffMaxMs", 0),
1002
+ booleanField("notify")
896
1003
  ]);
897
1004
  this.store = this.form.bind(() => this.projection(), import_client.createSnapshotStore);
898
1005
  }
@@ -908,7 +1015,11 @@ var AutoContinueSettingsCardController = class {
908
1015
  freshMs: this.form.field("freshMs"),
909
1016
  reconnectScanDelayMs: this.form.field("reconnectScanDelayMs"),
910
1017
  reconnectBackoffMs: this.form.field("reconnectBackoffMs"),
911
- verbose: this.form.field("verbose")
1018
+ verbose: this.form.field("verbose"),
1019
+ classify: this.form.field("classify"),
1020
+ backoffFactor: this.form.field("backoffFactor"),
1021
+ backoffMaxMs: this.form.field("backoffMaxMs"),
1022
+ notify: this.form.field("notify")
912
1023
  };
913
1024
  }
914
1025
  /**
@@ -1159,6 +1270,56 @@ function AutoContinueSettingsCard(props) {
1159
1270
  onEdit: (text) => props.edit("verbose", text),
1160
1271
  onReset: () => props.resetField("verbose")
1161
1272
  }
1273
+ ),
1274
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1275
+ BooleanField,
1276
+ {
1277
+ id: "auto-continue-classify",
1278
+ label: t("field.classify"),
1279
+ hint: t("field.classifyHint"),
1280
+ ...shared,
1281
+ ...state.classify,
1282
+ onEdit: (text) => props.edit("classify", text),
1283
+ onReset: () => props.resetField("classify")
1284
+ }
1285
+ ),
1286
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1287
+ ValueField,
1288
+ {
1289
+ id: "auto-continue-backoff-factor",
1290
+ label: t("field.backoffFactor"),
1291
+ hint: t("field.backoffFactorHint"),
1292
+ numeric: true,
1293
+ ...shared,
1294
+ ...state.backoffFactor,
1295
+ onEdit: (text) => props.edit("backoffFactor", text),
1296
+ onReset: () => props.resetField("backoffFactor")
1297
+ }
1298
+ ),
1299
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1300
+ ValueField,
1301
+ {
1302
+ id: "auto-continue-backoff-max",
1303
+ label: t("field.backoffMaxMs"),
1304
+ hint: t("field.backoffMaxMsHint"),
1305
+ numeric: true,
1306
+ ...shared,
1307
+ ...state.backoffMaxMs,
1308
+ onEdit: (text) => props.edit("backoffMaxMs", text),
1309
+ onReset: () => props.resetField("backoffMaxMs")
1310
+ }
1311
+ ),
1312
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1313
+ BooleanField,
1314
+ {
1315
+ id: "auto-continue-notify",
1316
+ label: t("field.notify"),
1317
+ hint: t("field.notifyHint"),
1318
+ ...shared,
1319
+ ...state.notify,
1320
+ onEdit: (text) => props.edit("notify", text),
1321
+ onReset: () => props.resetField("notify")
1322
+ }
1162
1323
  )
1163
1324
  ]
1164
1325
  }