@xmanrui/dsh-im 4.36.0 → 4.37.0

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.
Files changed (58) hide show
  1. package/README.en.md +14 -2
  2. package/README.md +13 -1
  3. package/lib/client.js +348 -195
  4. package/lib/index.js +314 -313
  5. package/package.json +2 -2
  6. package/plugin-src/client/bot-general-settings.js +83 -0
  7. package/plugin-src/client/channels/dingtalk/api.js +3 -1
  8. package/plugin-src/client/channels/feishu/api.js +3 -1
  9. package/plugin-src/client/channels/qq/api.js +3 -1
  10. package/plugin-src/client/channels/shared/token-api.js +3 -1
  11. package/plugin-src/client/channels/wecom/api.js +3 -1
  12. package/plugin-src/client/channels/wecom-app/api.js +3 -1
  13. package/plugin-src/client/channels/weixin/api.js +3 -1
  14. package/plugin-src/client/channels/whatsapp/api.js +3 -1
  15. package/plugin-src/client/connection-error.js +2 -1
  16. package/plugin-src/client/delivery-settings.js +5 -1
  17. package/plugin-src/client/i18n.js +13 -0
  18. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  19. package/plugin-src/host/channels/email/rpc.mjs +1 -0
  20. package/plugin-src/host/channels/feishu/rpc.mjs +13 -0
  21. package/plugin-src/host/channels/imessage/rpc.mjs +1 -0
  22. package/plugin-src/host/channels/matrix/rpc.mjs +10 -0
  23. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  24. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  25. package/plugin-src/host/channels/slack/rpc.mjs +10 -0
  26. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  27. package/plugin-src/host/channels/wecom-app/rpc.mjs +10 -0
  28. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  29. package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
  30. package/src/channels/dingtalk/dingtalk-bridge.mjs +31 -2
  31. package/src/channels/dingtalk/dingtalk-menu.mjs +1 -1
  32. package/src/channels/dingtalk/dingtalk-runtime.mjs +2 -1
  33. package/src/channels/discord/discord-runtime.mjs +3 -1
  34. package/src/channels/feishu/bridge.mjs +69 -5
  35. package/src/channels/feishu/feishu-cards.mjs +8 -4
  36. package/src/channels/feishu/slash-command-panel.mjs +1 -0
  37. package/src/channels/qq/qq-bridge.mjs +40 -2
  38. package/src/channels/qq/qq-menu.mjs +2 -2
  39. package/src/channels/shared/bot-workspace-store.mjs +69 -1
  40. package/src/channels/shared/busy-message-dispatcher.mjs +94 -0
  41. package/src/channels/shared/clear-command.mjs +85 -0
  42. package/src/channels/shared/command-catalog.mjs +3 -0
  43. package/src/channels/shared/command-permission.mjs +2 -0
  44. package/src/channels/shared/connection-error.mjs +1 -0
  45. package/src/channels/shared/control-command.mjs +20 -11
  46. package/src/channels/shared/diagnostic-details.mjs +7 -1
  47. package/src/channels/shared/harness-client.mjs +15 -8
  48. package/src/channels/shared/i18n-en/diagnostics.mjs +3 -0
  49. package/src/channels/shared/i18n-en/feishu.mjs +6 -4
  50. package/src/channels/shared/i18n-en/qq.mjs +2 -1
  51. package/src/channels/shared/i18n-en/shared-b.mjs +27 -0
  52. package/src/channels/shared/message-failure.mjs +1 -1
  53. package/src/channels/shared/message-mode.mjs +19 -0
  54. package/src/channels/shared/text-harness-bridge.mjs +48 -5
  55. package/src/channels/wecom/wecom-bridge.mjs +26 -2
  56. package/src/channels/wecom/wecom-cards.mjs +1 -1
  57. package/src/channels/wecom-app/wecom-app-bridge.mjs +26 -2
  58. package/src/channels/weixin/weixin-bridge.mjs +22 -2
@@ -218,7 +218,7 @@ export function classifyMessageFailure(error, {
218
218
  : businessCode;
219
219
  if (code === 'REQUEST_TIMEOUT' || code === 'NETWORK_ERROR') details.hint = connectionHint(details);
220
220
  return Object.freeze({
221
- ...(code === 'INTERNAL_UNKNOWN' || details.reason !== 'unknown' || details.httpStatus || details.providerCode ? { details } : {}),
221
+ ...(code === 'INTERNAL_UNKNOWN' || details.reason !== 'unknown' || details.httpStatus || details.providerCode || details.method ? { details } : {}),
222
222
  code,
223
223
  reason: safeReason ?? safeFailureReason(error?.code) ?? code,
224
224
  message: typeof userMessage === 'string' && userMessage.trim()
@@ -0,0 +1,19 @@
1
+ export const SET_MESSAGE_MODE_ENDPOINT = 'bot.message-mode.set';
2
+
3
+ export function normalizeBusyMessageMode(value) {
4
+ return value === 'steer' ? 'steer' : 'queue';
5
+ }
6
+
7
+ export function validateBusyMessageMode(value) {
8
+ if (value !== 'queue' && value !== 'steer') {
9
+ throw new TypeError('Message mode must be queue or steer');
10
+ }
11
+ return value;
12
+ }
13
+
14
+ export function validMessageModePayload(payload) {
15
+ return payload !== null && typeof payload === 'object' && !Array.isArray(payload)
16
+ && Object.keys(payload).length === 2
17
+ && typeof payload.botId === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(payload.botId)
18
+ && (payload.busyMessageMode === 'queue' || payload.busyMessageMode === 'steer');
19
+ }
@@ -1,3 +1,4 @@
1
+ import { BusyMessageDispatcher, canAutomaticallySteer } from './busy-message-dispatcher.mjs';
1
2
  import { randomUUID } from 'node:crypto';
2
3
 
3
4
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from './deferred-delivery-coordinator.mjs';
@@ -14,6 +15,7 @@ import {
14
15
  } from './context-enhancement.mjs';
15
16
  import { runWorkspaceCommand } from './workspace-command.mjs';
16
17
  import { runCompactCommand } from './compact-command.mjs';
18
+ import { isClearCommand, runClearCommand } from './clear-command.mjs';
17
19
  import { isHistoryCommand, runHistoryCommand } from './history-command.mjs';
18
20
  import {
19
21
  isControlCommand,
@@ -178,6 +180,7 @@ export class TextHarnessBridge {
178
180
  #signal;
179
181
  #keepaliveIntervalMs;
180
182
  #queues = new Map();
183
+ #busyMessages = new BusyMessageDispatcher();
181
184
  #pendingInteractions = new Map();
182
185
  #interactionKeys = new Map();
183
186
  // Keep the accepted configuration through the existing queue/reply lifecycle.
@@ -236,7 +239,7 @@ export class TextHarnessBridge {
236
239
  return structuredClone(this.#status);
237
240
  }
238
241
 
239
- accept(message, { contextSnapshot, accessDecision } = {}) {
242
+ accept(message, { contextSnapshot, accessDecision, busyMessageMode } = {}) {
240
243
  if (this.#signal?.aborted) return Promise.resolve();
241
244
  const conversationId = cleanText(message?.conversationId);
242
245
  const kind = message?.kind === 'group' ? 'group' : 'direct';
@@ -300,7 +303,7 @@ export class TextHarnessBridge {
300
303
  });
301
304
  normalized.statusReaction = statusReaction;
302
305
 
303
- const processing = this.#acceptAcceptedMessage(normalized, messageId, senderId);
306
+ const processing = this.#acceptAcceptedMessage(normalized, messageId, senderId, busyMessageMode);
304
307
  void processing.then(
305
308
  () => statusReaction.success(),
306
309
  () => statusReaction.error(),
@@ -308,7 +311,7 @@ export class TextHarnessBridge {
308
311
  return processing;
309
312
  }
310
313
 
311
- #acceptAcceptedMessage(normalized, messageId, senderId) {
314
+ #acceptAcceptedMessage(normalized, messageId, senderId, busyMessageMode) {
312
315
  if (normalized.kind === 'direct') {
313
316
  rememberConnectionTestTarget(
314
317
  this.#state,
@@ -319,6 +322,18 @@ export class TextHarnessBridge {
319
322
  const key = `${normalized.kind}:${normalized.conversationId}`;
320
323
  const pending = this.#pendingInteractions.get(key);
321
324
  const text = controlTextOf(normalized);
325
+ if (isClearCommand(text) && (
326
+ this.#queues.has(key)
327
+ || pending
328
+ || this.#approvals.hasPending(key)
329
+ || this.#batches.status(key).phase !== 'idle'
330
+ )) {
331
+ return this.#finishLocalMessage(
332
+ normalized,
333
+ messageId,
334
+ t('当前会话正在生成回复或等待交互,请先完成交互或发送 /stop,再执行 /clear。'),
335
+ );
336
+ }
322
337
  const batchCommand = isBatchInputCommand(text);
323
338
  if (batchCommand && normalized.kind === 'group' && normalized.addressed === true) {
324
339
  return this.#finishLocalMessage(
@@ -373,7 +388,8 @@ export class TextHarnessBridge {
373
388
  }
374
389
  const collectingBatch = normalized.kind === 'direct'
375
390
  && this.#batches.status(key).phase === 'collecting';
376
- const commandRunner = collectingBatch ? null : isHistoryCommand(text) ? runHistoryCommand
391
+ const commandRunner = collectingBatch ? null : isClearCommand(text) ? runClearCommand
392
+ : isHistoryCommand(text) ? runHistoryCommand
377
393
  : hasInboundFiles(normalized) ? null : isControlCommand(text)
378
394
  ? runControlCommand
379
395
  : (isModelCommand(text)
@@ -458,7 +474,33 @@ export class TextHarnessBridge {
458
474
  pending.queue = current;
459
475
  return current;
460
476
  }
461
- return this.#enqueueMessage(normalized, messageId, senderId, key);
477
+ return this.#busyMessages.dispatch({
478
+ key, messageId, text: normalized.content,
479
+ commandText: text, mode: busyMessageMode,
480
+ onFailure: (error) => this.#signal?.aborted || error?.code === 'turn-stopped'
481
+ ? normalized.statusReaction?.clear() : normalized.statusReaction?.error(),
482
+ eligible: () => normalized.plainText !== false
483
+ && !hasInboundImages(normalized) && !hasInboundFiles(normalized)
484
+ && !hasReplyReference(normalized)
485
+ && canAutomaticallySteer(this.#accessPolicy, normalized.kind,
486
+ [senderId, normalized.senderAlternateId].filter(Boolean)),
487
+ harness: this.#harness, state: this.#state, status: this.#status,
488
+ signal: this.#signal, control: { owner: this, key }, logger: this.#logger,
489
+ acceptedMessageIds: this.#acceptedMessageIds,
490
+ pendingInteraction: () => this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
491
+ enhancement: {
492
+ snapshot: this.#acceptedMessageIds.get(messageId),
493
+ source: () => {
494
+ const source = normalized.contextSource?.();
495
+ return { channel: this.#descriptor.key, senderId, senderName: source?.senderName,
496
+ conversationTitle: source?.conversationTitle,
497
+ chatId: source?.chatId ?? normalized.conversationId, threadId: source?.threadId };
498
+ },
499
+ },
500
+ isQueued: () => this.#queues.has(key),
501
+ enqueue: (options) => this.#enqueueMessage(normalized, messageId, senderId, key, options),
502
+ send: (reply) => this.#bot.sendText(normalized.replyTarget, reply),
503
+ });
462
504
  }
463
505
 
464
506
  #finishLocalMessage(message, messageId, reply, { recordReceived = true } = {}) {
@@ -522,6 +564,7 @@ export class TextHarnessBridge {
522
564
  }
523
565
 
524
566
  async waitForIdle() {
567
+ await this.#busyMessages.whenIdle();
525
568
  await Promise.allSettled([
526
569
  ...this.#queues.values(),
527
570
  ...[...this.#pendingInteractions.values()].flatMap((pending) => (
@@ -1,3 +1,4 @@
1
+ import { BusyMessageDispatcher, canAutomaticallySteer } from '../shared/busy-message-dispatcher.mjs';
1
2
  import { imageInputLimits } from '../shared/image-input-policy.mjs';
2
3
  import { imageDownloadLimitMessage } from '../shared/image-prompt.mjs';
3
4
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
@@ -20,6 +21,7 @@ import {
20
21
  isBatchInputCommand,
21
22
  } from '../shared/batch-input.mjs';
22
23
  import { runCompactCommand } from '../shared/compact-command.mjs';
24
+ import { isClearCommand, runClearCommand } from '../shared/clear-command.mjs';
23
25
  import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
24
26
  import {
25
27
  isControlCommand,
@@ -82,6 +84,7 @@ function helpText() {
82
84
  t('/menu 或 /m 打开可点击的功能菜单'),
83
85
  t('/new 开启一个全新会话'),
84
86
  t('/compact 压缩当前会话的较早上下文'),
87
+ t('/clear 清空当前会话上下文(保留 Session 绑定和历史记录)'),
85
88
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
86
89
  t('/workspace 工作区序号或绝对路径 切换工作区'),
87
90
  t('/workspacelist 列出工作区绝对路径'),
@@ -565,6 +568,7 @@ export class WecomHarnessBridge {
565
568
  #signal;
566
569
  #fileUploadTimeoutMs;
567
570
  #queues = new Map();
571
+ #busyMessages = new BusyMessageDispatcher();
568
572
  #pendingInteractions = new Map();
569
573
  #interactionKeys = new Map();
570
574
  // Keep the accepted configuration through the existing queue/reply lifecycle.
@@ -870,6 +874,7 @@ export class WecomHarnessBridge {
870
874
  return { message: t('已开启新会话。请发送你的问题。') };
871
875
  }
872
876
  return await runWorkspaceCommand(text, this.#harness, key)
877
+ ?? await runClearCommand(text, this.#harness, this.#state, key, options)
873
878
  ?? await runCompactCommand(text, this.#harness, this.#state, key, options);
874
879
  }
875
880
 
@@ -951,7 +956,8 @@ export class WecomHarnessBridge {
951
956
  const menuCommand = !hasInboundImages(commandMessage) && !hasInboundFiles(commandMessage)
952
957
  && (parseWecomMenu(commandText) || ['/help', '/status', '/new'].includes(commandText.toLowerCase())
953
958
  || this.#cardFrames.has(frame));
954
- const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
959
+ const commandRunner = isClearCommand(commandText) ? runClearCommand
960
+ : isHistoryCommand(commandText) ? runHistoryCommand
955
961
  : hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
956
962
  ? runControlCommand
957
963
  : (isModelCommand(commandText)
@@ -1048,7 +1054,23 @@ export class WecomHarnessBridge {
1048
1054
  pending.queue = current;
1049
1055
  return current;
1050
1056
  }
1051
- return this.#enqueueMessage(frame, messageId, key);
1057
+ return this.#busyMessages.dispatch({
1058
+ key, messageId, text: commandText,
1059
+ eligible: () => isNativeWecomText(frame) && !hasReplyReference(commandMessage)
1060
+ && !parseWecomMenu(commandText) && !this.#cardFrames.has(frame)
1061
+ && canAutomaticallySteer(this.#accessPolicy, conversationType, senderId),
1062
+ harness: this.#harness, state: this.#state, status: this.#status,
1063
+ signal: this.#signal, control: { owner: this, key }, logger: this.#logger,
1064
+ acceptedMessageIds: this.#acceptedMessageIds,
1065
+ pendingInteraction: () => this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
1066
+ enhancement: {
1067
+ snapshot: this.#acceptedMessageIds.get(messageId),
1068
+ source: () => ({ channel: 'wecom', senderId, chatId }),
1069
+ },
1070
+ isQueued: () => this.#queues.has(key),
1071
+ enqueue: (options) => this.#enqueueMessage(frame, messageId, key, options),
1072
+ send: (reply) => this.#sendImmediate(frame, chatId, reply),
1073
+ });
1052
1074
  }
1053
1075
 
1054
1076
  #enqueueMessage(frame, messageId, key, {
@@ -1151,6 +1173,7 @@ export class WecomHarnessBridge {
1151
1173
  }
1152
1174
 
1153
1175
  async waitForIdle() {
1176
+ await this.#busyMessages.whenIdle();
1154
1177
  await Promise.allSettled([
1155
1178
  ...this.#queues.values(),
1156
1179
  ...[...this.#pendingInteractions.values()].flatMap((pending) => (
@@ -1176,6 +1199,7 @@ export class WecomHarnessBridge {
1176
1199
  hasFiles: hasInboundFiles(message),
1177
1200
  pendingInteraction: this.#pendingInteractions.has(key)
1178
1201
  || this.#approvals.hasPending(key),
1202
+ busy: this.#queues.has(key) || this.#batchInputs.status(key).phase !== 'idle',
1179
1203
  control: { owner: this, key },
1180
1204
  deferredDelivery: this.#deferred,
1181
1205
  enhancement: captureContextEnhancementSource(
@@ -48,7 +48,7 @@ export function wecomMenu({ workspace, workspaces = [] } = {}) {
48
48
  entries: [
49
49
  [t('切换工作区'), 'select:workspace'],
50
50
  [t('🆕 新会话'), '/new'],
51
- [t('⏹ 停止'), '/stop'], [t('📐 压缩'), '/compact'],
51
+ [t('⏹ 停止'), '/stop'], [t('📐 压缩'), '/compact'], [t('🧹 清空上下文'), '/clear'],
52
52
  [t('📊 状态'), '/status'], [t('📖 帮助'), '/help'],
53
53
  ],
54
54
  };
@@ -1,3 +1,4 @@
1
+ import { BusyMessageDispatcher, canAutomaticallySteer } from '../shared/busy-message-dispatcher.mjs';
1
2
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
2
3
  import {
3
4
  harnessAnswerForQuestion,
@@ -11,6 +12,7 @@ import {
11
12
  isBatchInputCommand,
12
13
  } from '../shared/batch-input.mjs';
13
14
  import { runCompactCommand } from '../shared/compact-command.mjs';
15
+ import { isClearCommand, runClearCommand } from '../shared/clear-command.mjs';
14
16
  import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
15
17
  import {
16
18
  isControlCommand,
@@ -75,6 +77,7 @@ const HELP_TEXT = () => [
75
77
  t('直接发送文字或图片即可继续当前会话。'),
76
78
  t('/new 开启一个全新会话'),
77
79
  t('/compact 压缩当前会话的较早上下文'),
80
+ t('/clear 清空当前会话上下文(保留 Session 绑定和历史记录)'),
78
81
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
79
82
  t('/workspace 工作区序号或绝对路径 切换工作区'),
80
83
  t('/workspacelist 列出工作区绝对路径'),
@@ -202,6 +205,7 @@ export class WecomAppBridge {
202
205
  #streamRegistry;
203
206
  #maxChunkBytes;
204
207
  #queues = new Map();
208
+ #busyMessages = new BusyMessageDispatcher();
205
209
  #acceptedMessageIds = new Map();
206
210
  #commandTasks = new Set();
207
211
  #approvalTasks = new Set();
@@ -311,7 +315,8 @@ export class WecomAppBridge {
311
315
  return this.#finishBatchResult(messageId, key, sender, result);
312
316
  }
313
317
  }
314
- const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
318
+ const commandRunner = isClearCommand(commandText) ? runClearCommand
319
+ : isHistoryCommand(commandText) ? runHistoryCommand
315
320
  : isControlCommand(commandText)
316
321
  ? runControlCommand
317
322
  : (isModelCommand(commandText)
@@ -385,7 +390,24 @@ export class WecomAppBridge {
385
390
  pending.queue = current;
386
391
  return current;
387
392
  }
388
- return this.#enqueueMessage(message, messageId, key, { sink });
393
+ return this.#busyMessages.dispatch({
394
+ key, messageId, text: commandText,
395
+ eligible: () => message.msgtype === 'text' && !hasImages
396
+ && !hasReplyReference(wecomAppInboundMessage(message, this.#api))
397
+ && canAutomaticallySteer(this.#accessPolicy, 'direct', sender),
398
+ harness: this.#harness, state: this.#state, status: this.#status,
399
+ signal: this.#signal, control: { owner: this, key }, logger: this.#logger,
400
+ acceptedMessageIds: this.#acceptedMessageIds,
401
+ pendingInteraction: () => this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
402
+ enhancement: {
403
+ snapshot: this.#acceptedMessageIds.get(messageId),
404
+ source: () => ({ channel: 'wecom-app', senderId: sender, chatId: sender }),
405
+ },
406
+ isQueued: () => this.#queues.has(key),
407
+ enqueue: (options) => this.#enqueueMessage(message, messageId, key, { ...options, sink }),
408
+ send: (reply) => this.#send(sender, reply),
409
+ onError: (error) => this.#handleInteractionFailure(message, messageId, error),
410
+ });
389
411
  }
390
412
 
391
413
  #enqueueMessage(message, messageId, key, {
@@ -472,6 +494,7 @@ export class WecomAppBridge {
472
494
  }
473
495
 
474
496
  async waitForIdle() {
497
+ await this.#busyMessages.whenIdle();
475
498
  await Promise.allSettled([
476
499
  ...this.#queues.values(),
477
500
  ...[...this.#pendingInteractions.values()].flatMap((pending) => (
@@ -501,6 +524,7 @@ export class WecomAppBridge {
501
524
  hasFiles: false,
502
525
  pendingInteraction: this.#pendingInteractions.has(key)
503
526
  || this.#approvals.hasPending(key),
527
+ busy: this.#queues.has(key) || this.#batchInputs.status(key).phase !== 'idle',
504
528
  control: { owner: this, key },
505
529
  deferredDelivery: this.#deferred,
506
530
  enhancement: captureContextEnhancementSource(
@@ -1,3 +1,4 @@
1
+ import { BusyMessageDispatcher, canAutomaticallySteer } from '../shared/busy-message-dispatcher.mjs';
1
2
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
2
3
  import {
3
4
  DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
@@ -21,6 +22,7 @@ import {
21
22
  isBatchInputCommand,
22
23
  } from '../shared/batch-input.mjs';
23
24
  import { runCompactCommand } from '../shared/compact-command.mjs';
25
+ import { isClearCommand, runClearCommand } from '../shared/clear-command.mjs';
24
26
  import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
25
27
  import {
26
28
  isControlCommand,
@@ -91,6 +93,7 @@ const HELP_TEXT = () => [
91
93
  t('直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。'),
92
94
  t('/new 开启一个全新会话'),
93
95
  t('/compact 压缩当前会话的较早上下文'),
96
+ t('/clear 清空当前会话上下文(保留 Session 绑定和历史记录)'),
94
97
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
95
98
  t('/workspace 工作区序号或绝对路径 切换工作区'),
96
99
  t('/workspacelist 列出工作区绝对路径'),
@@ -313,6 +316,7 @@ export class WeixinHarnessBridge {
313
316
  #typingKeepaliveMs;
314
317
  #signal;
315
318
  #queues = new Map();
319
+ #busyMessages = new BusyMessageDispatcher();
316
320
  #pendingInteractions = new Map();
317
321
  #interactionKeys = new Map();
318
322
  // Keep the accepted configuration through the existing queue/reply lifecycle.
@@ -457,7 +461,8 @@ export class WeixinHarnessBridge {
457
461
  );
458
462
  }
459
463
  }
460
- const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
464
+ const commandRunner = isClearCommand(commandText) ? runClearCommand
465
+ : isHistoryCommand(commandText) ? runHistoryCommand
461
466
  : hasWeixinFileItems(message) ? null : isControlCommand(commandText)
462
467
  ? runControlCommand
463
468
  : (isModelCommand(commandText)
@@ -542,7 +547,20 @@ export class WeixinHarnessBridge {
542
547
  pending.queue = current;
543
548
  return current;
544
549
  }
545
- return this.#enqueueMessage(message, messageId, key);
550
+ return this.#busyMessages.dispatch({
551
+ key, messageId, text: commandText,
552
+ eligible: () => isNativeWeixinText(message) && !extractWeixinReplyReference(message)
553
+ && canAutomaticallySteer(this.#accessPolicy, 'direct', sender),
554
+ harness: this.#harness, state: this.#state, status: this.#status,
555
+ signal: this.#signal, control: { owner: this, key }, logger: this.#logger,
556
+ acceptedMessageIds: this.#acceptedMessageIds,
557
+ pendingInteraction: () => this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
558
+ enhancement: this.#acceptedMessageIds.get(messageId),
559
+ isQueued: () => this.#queues.has(key),
560
+ enqueue: (options) => this.#enqueueMessage(message, messageId, key, options),
561
+ send: (reply) => this.#sendOutOfBand(key, sender, reply, contextToken, runId),
562
+ onError: (error) => this.#handleInteractionFailure(message, messageId, error),
563
+ });
546
564
  }
547
565
 
548
566
  #enqueueMessage(message, messageId, key, {
@@ -684,6 +702,7 @@ export class WeixinHarnessBridge {
684
702
  }
685
703
 
686
704
  async waitForIdle() {
705
+ await this.#busyMessages.whenIdle();
687
706
  await Promise.allSettled([
688
707
  ...this.#queues.values(),
689
708
  ...[...this.#pendingInteractions.values()].flatMap((pending) => (
@@ -724,6 +743,7 @@ export class WeixinHarnessBridge {
724
743
  hasFiles: hasWeixinFileItems(message),
725
744
  pendingInteraction: this.#pendingInteractions.has(key)
726
745
  || this.#approvals.hasPending(key),
746
+ busy: this.#queues.has(key) || this.#batchInputs.status(key).phase !== 'idle',
727
747
  control: { owner: this, key },
728
748
  deferredDelivery: this.#deferred,
729
749
  enhancement: captureContextEnhancementSource(