dsh-client-auto-continue 0.9.0 → 0.10.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/README.md +20 -15
- package/README.zh.md +11 -6
- package/lib/client.js +71 -38
- package/lib/client.js.map +3 -3
- package/lib/index.js +101 -32
- package/lib/types/client/locales.d.ts +5 -5
- package/lib/types/host/engine.d.ts +1 -0
- package/lib/types/index.d.ts +5 -1
- package/lib/types/shared/core.d.ts +22 -1
- package/package.json +2 -2
- package/src/client/index.ts +12 -1
- package/src/client/locales.ts +12 -1
- package/src/client/settings-card.tsx +6 -6
- package/src/host/engine.ts +68 -16
- package/src/index.ts +24 -6
- package/src/shared/core.ts +37 -11
package/src/host/engine.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
todayKey,
|
|
35
35
|
toolResultFacts,
|
|
36
36
|
type AutoContinueConfig,
|
|
37
|
+
type AutoContinueLocale,
|
|
37
38
|
type DayStats,
|
|
38
39
|
type FailureFacts,
|
|
39
40
|
type SessionState,
|
|
@@ -42,6 +43,35 @@ import {
|
|
|
42
43
|
type TemplateContext,
|
|
43
44
|
} from '../shared/core.ts';
|
|
44
45
|
|
|
46
|
+
const NOTICE_COPY = {
|
|
47
|
+
zh: {
|
|
48
|
+
notContinuedTitle: 'dsh-auto-continue: 未自动继续',
|
|
49
|
+
permanentErrorBody: (sessionId: SessionId, summary: string) =>
|
|
50
|
+
`${sessionId}: 永久性错误 ${summary},需要人工处理`,
|
|
51
|
+
resumeAction: '立即续跑',
|
|
52
|
+
pauseAction: '暂停该会话 1 小时',
|
|
53
|
+
continuedTitle: 'dsh-auto-continue: 已自动继续',
|
|
54
|
+
continuedBody: (sessionId: SessionId, text: string, count: number) =>
|
|
55
|
+
`${sessionId}: 已发送「${text}」(第 ${count} 次连续)`,
|
|
56
|
+
stoppedTitle: 'dsh-auto-continue: 已停止自动继续',
|
|
57
|
+
stoppedBody: (sessionId: SessionId, count: number) =>
|
|
58
|
+
`${sessionId}: 连续失败 ${count} 次, 需要人工介入`,
|
|
59
|
+
},
|
|
60
|
+
en: {
|
|
61
|
+
notContinuedTitle: 'dsh-auto-continue: Not continued',
|
|
62
|
+
permanentErrorBody: (sessionId: SessionId, summary: string) =>
|
|
63
|
+
`${sessionId}: Permanent error ${summary}; manual intervention required`,
|
|
64
|
+
resumeAction: 'Resume now',
|
|
65
|
+
pauseAction: 'Pause this session for 1 hour',
|
|
66
|
+
continuedTitle: 'dsh-auto-continue: Continued automatically',
|
|
67
|
+
continuedBody: (sessionId: SessionId, text: string, count: number) =>
|
|
68
|
+
`${sessionId}: Sent "${text}" (consecutive attempt ${count})`,
|
|
69
|
+
stoppedTitle: 'dsh-auto-continue: Auto-continue stopped',
|
|
70
|
+
stoppedBody: (sessionId: SessionId, count: number) =>
|
|
71
|
+
`${sessionId}: ${count} consecutive failures; manual intervention required`,
|
|
72
|
+
},
|
|
73
|
+
} as const satisfies Record<AutoContinueLocale, Record<string, unknown>>;
|
|
74
|
+
|
|
45
75
|
/** 通知桥事件: host 引擎产生, browser 侧订阅展示(Notification / 动作按钮)。 */
|
|
46
76
|
export interface HostNotice {
|
|
47
77
|
/** 稳定标识(供 browser 去重)。 */
|
|
@@ -113,6 +143,7 @@ export class AutoContinueRunner {
|
|
|
113
143
|
private readonly notices: HostNotice[] = [];
|
|
114
144
|
private readonly noticeListeners = new Set<() => void>();
|
|
115
145
|
private readonly stateListeners = new Set<() => void>();
|
|
146
|
+
private readonly disposeSessionEvents: () => void;
|
|
116
147
|
private disposed = false;
|
|
117
148
|
|
|
118
149
|
/**
|
|
@@ -124,7 +155,9 @@ export class AutoContinueRunner {
|
|
|
124
155
|
private readonly getConfig: () => AutoContinueConfig,
|
|
125
156
|
) {
|
|
126
157
|
// 单实例事件源: 宿主进程内的会话事件 firehose, 天然覆盖所有会话。
|
|
127
|
-
ctx.on('session/event', (session, event) =>
|
|
158
|
+
this.disposeSessionEvents = ctx.on('session/event', (session, event) =>
|
|
159
|
+
this.onHostEvent(session, event),
|
|
160
|
+
);
|
|
128
161
|
const config = this.getConfig();
|
|
129
162
|
if (config.scanOnBoot) {
|
|
130
163
|
void this.bootScanLoop();
|
|
@@ -196,7 +229,9 @@ export class AutoContinueRunner {
|
|
|
196
229
|
}
|
|
197
230
|
|
|
198
231
|
dispose(): void {
|
|
232
|
+
if (this.disposed) return;
|
|
199
233
|
this.disposed = true;
|
|
234
|
+
this.disposeSessionEvents();
|
|
200
235
|
for (const state of this.states.values()) {
|
|
201
236
|
if (state.pendingTimer !== undefined) clearTimeout(state.pendingTimer);
|
|
202
237
|
if (state.loopRetryTimer !== undefined) clearTimeout(state.loopRetryTimer);
|
|
@@ -404,7 +439,11 @@ export class AutoContinueRunner {
|
|
|
404
439
|
if (state.loopRetryTimer !== undefined) clearTimeout(state.loopRetryTimer);
|
|
405
440
|
state.loopRetryTimer = setTimeout(() => {
|
|
406
441
|
state.loopRetryTimer = undefined;
|
|
407
|
-
|
|
442
|
+
try {
|
|
443
|
+
this.schedule(sessionId, 'loop:aborted');
|
|
444
|
+
} catch (error) {
|
|
445
|
+
console.error(`[auto-continue] loop 重启异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
446
|
+
}
|
|
408
447
|
}, remaining);
|
|
409
448
|
this.log(`loop 重启延迟 ${remaining}ms(冷却期) ${sessionId}`);
|
|
410
449
|
} else {
|
|
@@ -460,14 +499,15 @@ export class AutoContinueRunner {
|
|
|
460
499
|
private onTurnFailure(sessionId: SessionId, reason: string, failure: FailureFacts): void {
|
|
461
500
|
const config = this.getConfig();
|
|
462
501
|
if (config.classify && !isTransientFailure(failure, config.retryableErrorPatterns)) {
|
|
502
|
+
const copy = NOTICE_COPY[config.locale];
|
|
463
503
|
const summary = `${failure.code}${failure.status !== undefined ? ` (HTTP ${failure.status})` : ''}`;
|
|
464
504
|
this.log(`跳过 ${sessionId}(${reason}): 永久性失败 ${summary} — ${failure.message}`);
|
|
465
505
|
this.bumpStat({ skipped: 1, code: failure.code });
|
|
466
506
|
if (config.notify) {
|
|
467
507
|
this.notify(
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
this.notifyOptions(sessionId),
|
|
508
|
+
copy.notContinuedTitle,
|
|
509
|
+
copy.permanentErrorBody(sessionId, summary),
|
|
510
|
+
this.notifyOptions(sessionId, config.locale),
|
|
471
511
|
);
|
|
472
512
|
}
|
|
473
513
|
return;
|
|
@@ -476,11 +516,12 @@ export class AutoContinueRunner {
|
|
|
476
516
|
}
|
|
477
517
|
|
|
478
518
|
/** 通知操作按钮与回调(「立即续跑」/「暂停该会话 1 小时」)。 */
|
|
479
|
-
private notifyOptions(sessionId: SessionId): NotifyOptions {
|
|
519
|
+
private notifyOptions(sessionId: SessionId, locale: AutoContinueLocale): NotifyOptions {
|
|
520
|
+
const copy = NOTICE_COPY[locale];
|
|
480
521
|
return {
|
|
481
522
|
actions: [
|
|
482
|
-
{ action: 'resume', title:
|
|
483
|
-
{ action: 'pause1h', title:
|
|
523
|
+
{ action: 'resume', title: copy.resumeAction },
|
|
524
|
+
{ action: 'pause1h', title: copy.pauseAction },
|
|
484
525
|
],
|
|
485
526
|
onAction: (action) => this.onNotifyAction(sessionId, action),
|
|
486
527
|
};
|
|
@@ -559,7 +600,11 @@ export class AutoContinueRunner {
|
|
|
559
600
|
clearTimeout(state.pendingTimer);
|
|
560
601
|
state.pendingTimer = undefined;
|
|
561
602
|
}
|
|
562
|
-
|
|
603
|
+
try {
|
|
604
|
+
await this.fire(sessionId, 'manual:notification', true);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
console.error(`[auto-continue] 手动续跑异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
607
|
+
}
|
|
563
608
|
}
|
|
564
609
|
|
|
565
610
|
/** 本会话当前生效的冷却间隔(自适应退避)。 */
|
|
@@ -596,7 +641,12 @@ export class AutoContinueRunner {
|
|
|
596
641
|
const timer = setTimeout(() => {
|
|
597
642
|
if (state.pendingTimer !== timer) return;
|
|
598
643
|
state.pendingTimer = undefined;
|
|
599
|
-
|
|
644
|
+
// 保险丝: 定时器回调内任何异常(含 inactive context)都不得成为未捕获异常炸掉进程。
|
|
645
|
+
try {
|
|
646
|
+
void this.fire(sessionId, reason);
|
|
647
|
+
} catch (error) {
|
|
648
|
+
console.error(`[auto-continue] 定时发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
649
|
+
}
|
|
600
650
|
}, config.graceMs);
|
|
601
651
|
state.pendingTimer = timer;
|
|
602
652
|
const template = reason.startsWith('loop:')
|
|
@@ -668,20 +718,22 @@ export class AutoContinueRunner {
|
|
|
668
718
|
this.bumpStat({ sent: 1, ...(state.lastFailure !== undefined ? { code: state.lastFailure.code } : {}) });
|
|
669
719
|
this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
|
|
670
720
|
if (config.notify) {
|
|
721
|
+
const copy = NOTICE_COPY[config.locale];
|
|
671
722
|
this.notify(
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
this.notifyOptions(sessionId),
|
|
723
|
+
copy.continuedTitle,
|
|
724
|
+
copy.continuedBody(sessionId, text, state.consecutive),
|
|
725
|
+
this.notifyOptions(sessionId, config.locale),
|
|
675
726
|
);
|
|
676
727
|
}
|
|
677
728
|
if (state.consecutive >= config.maxConsecutive) {
|
|
678
729
|
this.bumpStat({ gaveUp: 1 });
|
|
679
730
|
this.log(`达到连续上限 ${config.maxConsecutive} 次, 停止自动继续 ${sessionId}`);
|
|
680
731
|
if (config.notify) {
|
|
732
|
+
const copy = NOTICE_COPY[config.locale];
|
|
681
733
|
this.notify(
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
this.notifyOptions(sessionId),
|
|
734
|
+
copy.stoppedTitle,
|
|
735
|
+
copy.stoppedBody(sessionId, state.consecutive),
|
|
736
|
+
this.notifyOptions(sessionId, config.locale),
|
|
685
737
|
);
|
|
686
738
|
}
|
|
687
739
|
}
|
package/src/index.ts
CHANGED
|
@@ -24,22 +24,24 @@ import type {} from '@deepseek-ai/dsh-session';
|
|
|
24
24
|
/** Settings namespace of the auto-continue plugin (lowercase kebab-case). */
|
|
25
25
|
export const AUTO_CONTINUE_NS = 'auto-continue';
|
|
26
26
|
|
|
27
|
-
/** Wire schema
|
|
27
|
+
/** Wire schema; blank localized text fields tell resolveConfig() to select the active locale's defaults. */
|
|
28
28
|
export const AutoContinueSchema = z.object({
|
|
29
|
+
/** Active browser/UI locale mirrored by the client. */
|
|
30
|
+
locale: z.string().default('zh'),
|
|
29
31
|
/** Text automatically sent after an interruption. */
|
|
30
|
-
continueText: z.string().default('
|
|
32
|
+
continueText: z.string().default(''),
|
|
31
33
|
/** Text sent when the output token ceiling is reached (same placeholders as `continueText`). */
|
|
32
|
-
continueTextMaxTokens: z.string().default('
|
|
34
|
+
continueTextMaxTokens: z.string().default(''),
|
|
33
35
|
/** Idempotency guard: inspect the last tool call before resuming and steer the model. */
|
|
34
36
|
guardTools: z.boolean().default(true),
|
|
35
37
|
/** Guard text appended when the last tool call has no confirmed result (it may have partially executed). */
|
|
36
38
|
guardPendingText: z
|
|
37
39
|
.string()
|
|
38
|
-
.default('
|
|
40
|
+
.default(''),
|
|
39
41
|
/** Guard text appended when the last tool call completed successfully (don't rerun it). */
|
|
40
42
|
guardDoneText: z
|
|
41
43
|
.string()
|
|
42
|
-
.default('
|
|
44
|
+
.default(''),
|
|
43
45
|
/** Grace period after an interruption before auto-sending (ms). */
|
|
44
46
|
graceMs: z.natural().default(3000),
|
|
45
47
|
/** Minimum interval between two auto-continues per session (ms). */
|
|
@@ -81,7 +83,7 @@ export const AutoContinueSchema = z.object({
|
|
|
81
83
|
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|
|
82
84
|
loopText: z
|
|
83
85
|
.string()
|
|
84
|
-
.default('
|
|
86
|
+
.default(''),
|
|
85
87
|
});
|
|
86
88
|
|
|
87
89
|
/**
|
|
@@ -96,11 +98,19 @@ export function apply(ctx: Context): void {
|
|
|
96
98
|
});
|
|
97
99
|
});
|
|
98
100
|
|
|
101
|
+
// 引擎引用: inject 回调可能重入(依赖组合变化), 顶层 effect 在 fiber 卸载时必跑。
|
|
102
|
+
// dispose 绑定必须挂在 apply 的顶层 ctx 上——挂 inject 派生 ctx 的 effect 在 config HMR
|
|
103
|
+
// 替换行时不会执行, 悬空定时器会撞上 inactive context 炸掉整个进程。
|
|
104
|
+
let runnerRef: AutoContinueRunner | undefined;
|
|
105
|
+
|
|
99
106
|
// 单实例引擎: host 进程内监听会话事件, 所有标签页共享同一个引擎。
|
|
100
107
|
ctx.inject(['settings', 'agents', 'webServer'], (engineCtx) => {
|
|
108
|
+
// 回调重入时先清理旧引擎, 避免定时器与监听器叠加。
|
|
109
|
+
if (runnerRef !== undefined) runnerRef.dispose();
|
|
101
110
|
const runner = new AutoContinueRunner(engineCtx, () =>
|
|
102
111
|
resolveConfig(engineCtx.settings.get(settingsNamespace(AUTO_CONTINUE_NS)) as AutoContinueSettings | undefined),
|
|
103
112
|
);
|
|
113
|
+
runnerRef = runner;
|
|
104
114
|
|
|
105
115
|
// 状态桥: browser 侧订阅通知与运行时状态(SSE)。
|
|
106
116
|
const sseClients = new Set<(data: string) => void>();
|
|
@@ -176,4 +186,12 @@ export function apply(ctx: Context): void {
|
|
|
176
186
|
},
|
|
177
187
|
});
|
|
178
188
|
});
|
|
189
|
+
|
|
190
|
+
// 顶层生命周期绑定: fiber 卸载时清理引擎(定时器/监听器)。
|
|
191
|
+
// 挂在 apply 的 ctx 上保证 Cordis 一定会调用, 防止 inactive context 崩溃。
|
|
192
|
+
ctx.effect(() => () => {
|
|
193
|
+
const runner = runnerRef;
|
|
194
|
+
runnerRef = undefined;
|
|
195
|
+
if (runner !== undefined) runner.dispose();
|
|
196
|
+
});
|
|
179
197
|
}
|
package/src/shared/core.ts
CHANGED
|
@@ -6,8 +6,34 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types';
|
|
8
8
|
|
|
9
|
+
/** Supported UI/config locales. Any unknown browser locale falls back to Chinese. */
|
|
10
|
+
export type AutoContinueLocale = 'en' | 'zh';
|
|
11
|
+
|
|
12
|
+
/** Locale-owned defaults for the user-editable text fields. */
|
|
13
|
+
export const LOCALIZED_TEXT_DEFAULTS = {
|
|
14
|
+
zh: {
|
|
15
|
+
continueText: '继续',
|
|
16
|
+
continueTextMaxTokens: '继续',
|
|
17
|
+
guardPendingText: '(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)',
|
|
18
|
+
guardDoneText: '(上一步工具「{tool}」已完成, 结果: {result}; 不要重复执行, 直接继续)',
|
|
19
|
+
loopText: '(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)',
|
|
20
|
+
},
|
|
21
|
+
en: {
|
|
22
|
+
continueText: 'Continue',
|
|
23
|
+
continueTextMaxTokens: 'Continue',
|
|
24
|
+
guardPendingText:
|
|
25
|
+
'(The previous tool "{tool}" may not have completed. Check its state before continuing and do not run it again.)',
|
|
26
|
+
guardDoneText:
|
|
27
|
+
'(The previous tool "{tool}" completed successfully. Result: {result}; do not run it again. Continue from there.)',
|
|
28
|
+
loopText:
|
|
29
|
+
'(You may be stuck in a loop. Stop repeating the last action and continue with a different approach.)',
|
|
30
|
+
},
|
|
31
|
+
} as const satisfies Record<AutoContinueLocale, Record<string, string>>;
|
|
32
|
+
|
|
9
33
|
/** The `auto-continue` settings section (all fields optional on the wire; the host schema carries defaults). */
|
|
10
34
|
export interface AutoContinueSettings {
|
|
35
|
+
/** Active browser/UI locale mirrored by the client. */
|
|
36
|
+
locale?: AutoContinueLocale;
|
|
11
37
|
/** Text automatically sent after an interruption. */
|
|
12
38
|
continueText?: string;
|
|
13
39
|
/** Text sent when the output token ceiling is reached (same placeholders as `continueText`). */
|
|
@@ -63,13 +89,11 @@ export interface AutoContinueSettings {
|
|
|
63
89
|
/** Fully resolved configuration (built-in defaults + user overrides). */
|
|
64
90
|
export type AutoContinueConfig = Required<AutoContinueSettings>;
|
|
65
91
|
|
|
66
|
-
/**
|
|
92
|
+
/** Effective built-in defaults; localized text fields use Chinese until a browser locale is mirrored. */
|
|
67
93
|
export const DEFAULT_CONFIG: AutoContinueConfig = {
|
|
68
|
-
|
|
69
|
-
|
|
94
|
+
locale: 'zh',
|
|
95
|
+
...LOCALIZED_TEXT_DEFAULTS.zh,
|
|
70
96
|
guardTools: true,
|
|
71
|
-
guardPendingText: '(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)',
|
|
72
|
-
guardDoneText: '(上一步工具「{tool}」已完成, 结果: {result}; 不要重复执行, 直接继续)',
|
|
73
97
|
graceMs: 3000,
|
|
74
98
|
cooldownMs: 20000,
|
|
75
99
|
maxConsecutive: 3,
|
|
@@ -89,7 +113,6 @@ export const DEFAULT_CONFIG: AutoContinueConfig = {
|
|
|
89
113
|
loopShortCount: 12,
|
|
90
114
|
loopRepeatText: 4,
|
|
91
115
|
loopToolRepeat: 5,
|
|
92
|
-
loopText: '(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)',
|
|
93
116
|
};
|
|
94
117
|
|
|
95
118
|
function numberOr(value: unknown, fallback: number): number {
|
|
@@ -103,23 +126,26 @@ function booleanOr(value: unknown, fallback: boolean): boolean {
|
|
|
103
126
|
/** Resolve a (possibly partial / not-yet-loaded) settings section to a full config. */
|
|
104
127
|
export function resolveConfig(section: AutoContinueSettings | undefined): AutoContinueConfig {
|
|
105
128
|
const value = section ?? {};
|
|
129
|
+
const locale: AutoContinueLocale = value.locale === 'en' ? 'en' : 'zh';
|
|
130
|
+
const localized = LOCALIZED_TEXT_DEFAULTS[locale];
|
|
106
131
|
const text =
|
|
107
132
|
typeof value.continueText === 'string' && value.continueText.trim() !== ''
|
|
108
133
|
? value.continueText
|
|
109
|
-
:
|
|
134
|
+
: localized.continueText;
|
|
110
135
|
const maxTokensText =
|
|
111
136
|
typeof value.continueTextMaxTokens === 'string' && value.continueTextMaxTokens.trim() !== ''
|
|
112
137
|
? value.continueTextMaxTokens
|
|
113
|
-
:
|
|
138
|
+
: localized.continueTextMaxTokens;
|
|
114
139
|
const guardPendingText =
|
|
115
140
|
typeof value.guardPendingText === 'string' && value.guardPendingText.trim() !== ''
|
|
116
141
|
? value.guardPendingText
|
|
117
|
-
:
|
|
142
|
+
: localized.guardPendingText;
|
|
118
143
|
const guardDoneText =
|
|
119
144
|
typeof value.guardDoneText === 'string' && value.guardDoneText.trim() !== ''
|
|
120
145
|
? value.guardDoneText
|
|
121
|
-
:
|
|
146
|
+
: localized.guardDoneText;
|
|
122
147
|
return {
|
|
148
|
+
locale,
|
|
123
149
|
continueText: text,
|
|
124
150
|
continueTextMaxTokens: maxTokensText,
|
|
125
151
|
guardTools: booleanOr(value.guardTools, DEFAULT_CONFIG.guardTools),
|
|
@@ -150,7 +176,7 @@ export function resolveConfig(section: AutoContinueSettings | undefined): AutoCo
|
|
|
150
176
|
loopText:
|
|
151
177
|
typeof value.loopText === 'string' && value.loopText.trim() !== ''
|
|
152
178
|
? value.loopText
|
|
153
|
-
:
|
|
179
|
+
: localized.loopText,
|
|
154
180
|
};
|
|
155
181
|
}
|
|
156
182
|
|