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.
@@ -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) => this.onHostEvent(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
- this.schedule(sessionId, 'loop:aborted');
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
- 'dsh-auto-continue: 未自动继续',
469
- `${sessionId}: 永久性错误 ${summary},需要人工处理`,
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: '暂停该会话 1 小时' },
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
- await this.fire(sessionId, 'manual:notification', true);
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
- void this.fire(sessionId, reason);
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
- 'dsh-auto-continue: 已自动继续',
673
- `${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`,
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
- 'dsh-auto-continue: 已停止自动继续',
683
- `${sessionId}: 连续失败 ${state.consecutive} 次, 需要人工介入`,
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 of the auto-continue section; defaults are the plugin's built-in values. */
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('(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)'),
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('(上一步工具「{tool}」已完成, 结果: {result}; 不要重复执行, 直接继续)'),
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
  }
@@ -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
- /** Built-in defaults must match the host schema defaults in src/index.ts. */
92
+ /** Effective built-in defaults; localized text fields use Chinese until a browser locale is mirrored. */
67
93
  export const DEFAULT_CONFIG: AutoContinueConfig = {
68
- continueText: '继续',
69
- continueTextMaxTokens: '继续',
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
- : DEFAULT_CONFIG.continueText;
134
+ : localized.continueText;
110
135
  const maxTokensText =
111
136
  typeof value.continueTextMaxTokens === 'string' && value.continueTextMaxTokens.trim() !== ''
112
137
  ? value.continueTextMaxTokens
113
- : DEFAULT_CONFIG.continueTextMaxTokens;
138
+ : localized.continueTextMaxTokens;
114
139
  const guardPendingText =
115
140
  typeof value.guardPendingText === 'string' && value.guardPendingText.trim() !== ''
116
141
  ? value.guardPendingText
117
- : DEFAULT_CONFIG.guardPendingText;
142
+ : localized.guardPendingText;
118
143
  const guardDoneText =
119
144
  typeof value.guardDoneText === 'string' && value.guardDoneText.trim() !== ''
120
145
  ? value.guardDoneText
121
- : DEFAULT_CONFIG.guardDoneText;
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
- : DEFAULT_CONFIG.loopText,
179
+ : localized.loopText,
154
180
  };
155
181
  }
156
182