@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
@@ -1,4 +1,5 @@
1
1
  import { t } from './i18n.mjs';
2
+ import { normalizeBusyMessageMode, validateBusyMessageMode } from './message-mode.mjs';
2
3
  import { atConnectionStage, connectionStageError, extractConnectionEvidence } from './connection-error.mjs';
3
4
  import { validateBotAlias, withBotAlias } from './bot-alias.mjs';
4
5
  import { defaultImWorkspace, ensureImWorkspaceDirectory, sameWorkspacePath } from './default-workspace.mjs';
@@ -307,6 +308,13 @@ function normalizeDocument(value) {
307
308
  } catch { /* A damaged display name must not disable the bot. */ }
308
309
  }
309
310
  }
311
+ const busyMessageModes = Object.create(null);
312
+ if (value.busyMessageModes && typeof value.busyMessageModes === 'object'
313
+ && !Array.isArray(value.busyMessageModes)) {
314
+ for (const [id, mode] of Object.entries(value.busyMessageModes)) {
315
+ if (Object.hasOwn(workspaces, id) && mode === 'steer') busyMessageModes[id] = mode;
316
+ }
317
+ }
310
318
  const accessPolicies = normalizeAccessPolicies(value.accessPolicies, workspaces);
311
319
  const version = Math.max(
312
320
  value.version,
@@ -324,6 +332,7 @@ function normalizeDocument(value) {
324
332
  deliveryTargets,
325
333
  accessPolicies,
326
334
  aliases,
335
+ busyMessageModes,
327
336
  };
328
337
  }
329
338
 
@@ -337,8 +346,10 @@ function storedDocument({
337
346
  deliveryTargets,
338
347
  accessPolicies,
339
348
  aliases,
349
+ busyMessageModes,
340
350
  }) {
341
351
  const document = { version, workspaces };
352
+ if (Object.keys(busyMessageModes).length > 0) document.busyMessageModes = busyMessageModes;
342
353
  if (Object.keys(aliases).length > 0) document.aliases = aliases;
343
354
  if (Object.keys(conversationWorkspaces).length > 0) {
344
355
  document.conversationWorkspaces = conversationWorkspaces;
@@ -400,6 +411,7 @@ export class BotWorkspaceStore {
400
411
  #agentPresets = {};
401
412
  #models = {};
402
413
  #aliases = Object.create(null);
414
+ #busyMessageModes = Object.create(null);
403
415
  #contextEnhancement = {};
404
416
  #deliveryTargets = Object.create(null);
405
417
  #accessPolicies = Object.create(null);
@@ -431,6 +443,7 @@ export class BotWorkspaceStore {
431
443
  this.#agentPresets = normalized.agentPresets;
432
444
  this.#models = normalized.models;
433
445
  this.#aliases = normalized.aliases;
446
+ this.#busyMessageModes = normalized.busyMessageModes;
434
447
  this.#contextEnhancement = normalized.contextEnhancement;
435
448
  this.#deliveryTargets = normalized.deliveryTargets;
436
449
  this.#accessPolicies = normalized.accessPolicies;
@@ -442,6 +455,7 @@ export class BotWorkspaceStore {
442
455
  this.#agentPresets = {};
443
456
  this.#models = {};
444
457
  this.#aliases = Object.create(null);
458
+ this.#busyMessageModes = Object.create(null);
445
459
  this.#contextEnhancement = {};
446
460
  this.#deliveryTargets = Object.create(null);
447
461
  this.#accessPolicies = Object.create(null);
@@ -519,6 +533,11 @@ export class BotWorkspaceStore {
519
533
  return this.has(id) && Object.hasOwn(this.#aliases, id) ? this.#aliases[id] : '';
520
534
  }
521
535
 
536
+ busyMessageModeFor(botId) {
537
+ const id = botIdOf(botId);
538
+ return normalizeBusyMessageMode(this.has(id) ? this.#busyMessageModes[id] : undefined);
539
+ }
540
+
522
541
  contextEnhancementFor(botId) {
523
542
  const id = botIdOf(botId);
524
543
  return this.has(id) && Object.hasOwn(this.#contextEnhancement, id)
@@ -890,6 +909,27 @@ export class BotWorkspaceStore {
890
909
  });
891
910
  }
892
911
 
912
+ async setBusyMessageMode(botId, value, { incarnation } = {}) {
913
+ const id = botIdOf(botId);
914
+ const expectedIncarnation = incarnation === undefined ? this.incarnationFor(id) : incarnation;
915
+ const mode = validateBusyMessageMode(value);
916
+ return this.#enqueue(id, async () => {
917
+ if (!this.has(id) || expectedIncarnation !== this.incarnationFor(id)) {
918
+ const error = new Error('找不到要修改的机器人。');
919
+ error.code = 'workspace-bot-not-found';
920
+ throw error;
921
+ }
922
+ const next = { ...this.#busyMessageModes };
923
+ if (mode === 'steer') next[id] = mode;
924
+ else delete next[id];
925
+ // Publish only after the same atomic write used by the other bot settings.
926
+ await this.#persist(this.#contextEnhancement, this.#deliveryTargets,
927
+ this.#version, this.#accessPolicies, this.#aliases, this.#conversationWorkspaces, next);
928
+ this.#busyMessageModes = next;
929
+ return mode;
930
+ });
931
+ }
932
+
893
933
  async setContextEnhancement(botId, value, { incarnation } = {}) {
894
934
  const id = botIdOf(botId);
895
935
  const expectedIncarnation = incarnation === undefined ? this.incarnationFor(id) : incarnation;
@@ -1253,6 +1293,7 @@ export class BotWorkspaceStore {
1253
1293
  ...Object.keys(this.#agentPresets),
1254
1294
  ...Object.keys(this.#models),
1255
1295
  ...Object.keys(this.#aliases),
1296
+ ...Object.keys(this.#busyMessageModes),
1256
1297
  ...Object.keys(this.#contextEnhancement),
1257
1298
  ...Object.keys(this.#deliveryTargets),
1258
1299
  ...Object.keys(this.#accessPolicies),
@@ -1276,6 +1317,7 @@ export class BotWorkspaceStore {
1276
1317
  agentPreset: this.agentPresetFor(bot.botId),
1277
1318
  model: this.modelFor(bot.botId),
1278
1319
  contextEnhancement: this.contextEnhancementFor(bot.botId),
1320
+ busyMessageMode: this.busyMessageModeFor(bot.botId),
1279
1321
  accessPolicy: this.accessPolicyFor(bot.botId),
1280
1322
  }
1281
1323
  : bot),
@@ -1314,17 +1356,19 @@ export class BotWorkspaceStore {
1314
1356
  const hadPreset = Object.hasOwn(this.#agentPresets, id);
1315
1357
  const hadModel = Object.hasOwn(this.#models, id);
1316
1358
  const hadAlias = Object.hasOwn(this.#aliases, id);
1359
+ const hadMessageMode = Object.hasOwn(this.#busyMessageModes, id);
1317
1360
  const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
1318
1361
  const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
1319
1362
  const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
1320
1363
  const hadConversationWorkspaces = Object.hasOwn(this.#conversationWorkspaces, id);
1321
1364
  const needsCleanup = hadWorkspace || hadPreset || hadModel || hadAlias || hadContextEnhancement
1322
- || hadDeliveryTargets || hadAccessPolicy || hadConversationWorkspaces
1365
+ || hadDeliveryTargets || hadAccessPolicy || hadConversationWorkspaces || hadMessageMode
1323
1366
  || this.#dirtyRemovals.has(id);
1324
1367
  delete this.#workspaces[id];
1325
1368
  delete this.#agentPresets[id];
1326
1369
  delete this.#models[id];
1327
1370
  delete this.#aliases[id];
1371
+ delete this.#busyMessageModes[id];
1328
1372
  delete this.#contextEnhancement[id];
1329
1373
  delete this.#deliveryTargets[id];
1330
1374
  delete this.#accessPolicies[id];
@@ -1365,6 +1409,7 @@ export class BotWorkspaceStore {
1365
1409
  accessPolicies = this.#accessPolicies,
1366
1410
  aliases = this.#aliases,
1367
1411
  conversationWorkspaces = this.#conversationWorkspaces,
1412
+ busyMessageModes = this.#busyMessageModes,
1368
1413
  ) {
1369
1414
  await writeStoredDocument(this.#path, storedDocument({
1370
1415
  version,
@@ -1376,6 +1421,7 @@ export class BotWorkspaceStore {
1376
1421
  deliveryTargets,
1377
1422
  accessPolicies,
1378
1423
  aliases,
1424
+ busyMessageModes,
1379
1425
  }));
1380
1426
  this.#dirtyRemovals.clear();
1381
1427
  }
@@ -1385,6 +1431,7 @@ export class BotWorkspaceStore {
1385
1431
  || Object.keys(this.#agentPresets).length > 0
1386
1432
  || Object.keys(this.#models).length > 0
1387
1433
  || Object.keys(this.#aliases).length > 0
1434
+ || Object.keys(this.#busyMessageModes).length > 0
1388
1435
  || Object.keys(this.#contextEnhancement).length > 0
1389
1436
  || Object.keys(this.#deliveryTargets).length > 0
1390
1437
  || Object.keys(this.#accessPolicies).length > 0
@@ -1689,6 +1736,9 @@ export function createBotWorkspaceScope(
1689
1736
  }
1690
1737
  };
1691
1738
  }
1739
+ if (property === 'currentBusyMessageMode') {
1740
+ return () => isCurrentScope() ? workspaces.busyMessageModeFor(botId) : 'queue';
1741
+ }
1692
1742
  if (property === 'currentWorkspace') {
1693
1743
  return () => {
1694
1744
  if (!isCurrentScope()) {
@@ -2252,6 +2302,23 @@ export function createWorkspaceAwareController(controller, {
2252
2302
  return result;
2253
2303
  });
2254
2304
  };
2305
+ const updateBusyMessageMode = (botId, value, projectStatus) => {
2306
+ const incarnation = workspaces.incarnationFor(botId);
2307
+ const busyMessageMode = validateBusyMessageMode(value);
2308
+ return withBotTransition(botId, async () => {
2309
+ const snapshot = await decorate(await controller.status());
2310
+ if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
2311
+ const error = new Error('找不到要修改的机器人。');
2312
+ error.code = 'workspace-bot-not-found';
2313
+ throw error;
2314
+ }
2315
+ const updated = { ...snapshot, bots: snapshot.bots.map((bot) => bot.botId === botId
2316
+ ? { ...bot, busyMessageMode } : bot) };
2317
+ const result = projectStatus ? await projectStatus(updated) : updated;
2318
+ await workspaces.setBusyMessageMode(botId, busyMessageMode, { incarnation });
2319
+ return result;
2320
+ });
2321
+ };
2255
2322
  const updateContextEnhancement = (botId, value, projectStatus) => {
2256
2323
  const incarnation = workspaces.incarnationFor(botId);
2257
2324
  const config = validateContextEnhancementConfig(value);
@@ -2369,6 +2436,7 @@ export function createWorkspaceAwareController(controller, {
2369
2436
  if (property === 'updateAgentPreset') return updateAgentPreset;
2370
2437
  if (property === 'updateModel') return updateModel;
2371
2438
  if (property === 'updateAlias') return updateAlias;
2439
+ if (property === 'updateBusyMessageMode') return updateBusyMessageMode;
2372
2440
  if (property === 'updateContextEnhancement') return updateContextEnhancement;
2373
2441
  if (property === 'updateAccessPolicy') return updateAccessPolicy;
2374
2442
  const value = Reflect.get(target, property, target);
@@ -0,0 +1,94 @@
1
+ import { steerMessage } from './control-command.mjs';
2
+ import { isSharedLocalCommand } from './command-permission.mjs';
3
+ import { evaluateInboundAccess } from './inbound-access.mjs';
4
+ import { normalizeBusyMessageMode } from './message-mode.mjs';
5
+ import { t } from './i18n.mjs';
6
+ import {
7
+ messageFailureDiagnostic, messageFailureText, setLastMessageFailure,
8
+ } from './message-failure.mjs';
9
+
10
+ export function canAutomaticallySteer(policy, conversationType, senderIds) {
11
+ // Legacy bridges have already checked their owner/allowlist at acceptance.
12
+ return !policy || evaluateInboundAccess(policy, {
13
+ conversationType, senderIds, text: '/steer', isCommand: true,
14
+ }).allowed;
15
+ }
16
+
17
+ /** Serialize only routing/submission, leaving the existing task queues intact. */
18
+ export class BusyMessageDispatcher {
19
+ #tails = new Map();
20
+ #tasks = new Set();
21
+
22
+ dispatch({
23
+ key, messageId, text, commandText = text, eligible, harness, state, status,
24
+ control, signal, enhancement, pendingInteraction, acceptedMessageIds,
25
+ mode = harness.currentBusyMessageMode?.(), onFailure,
26
+ enqueue, isQueued, send, logger = console, onSteered, onError,
27
+ }) {
28
+ // Read once, before any await: a settings change cannot reinterpret this input.
29
+ const steer = normalizeBusyMessageMode(mode) === 'steer'
30
+ && (typeof eligible === 'function' ? eligible() : eligible) && Boolean(text?.trim()) && !isSharedLocalCommand(commandText);
31
+ const report = async (error) => {
32
+ onFailure?.(error);
33
+ if (signal?.aborted || error?.code === 'turn-stopped') return;
34
+ if (onError) return onError(error);
35
+ status.lastError = error?.message ?? String(error);
36
+ const failure = setLastMessageFailure(status, error);
37
+ logger.error?.('[dsh-im] failed to route a message:', messageFailureDiagnostic(error, failure));
38
+ await Promise.resolve().then(() => send(messageFailureText(failure))).catch(() => undefined);
39
+ };
40
+ const queue = (alreadyRecorded = false) => {
41
+ const waiting = isQueued();
42
+ // Reserve FIFO position before sending the optional receipt.
43
+ const completion = enqueue({ alreadyRecorded });
44
+ const receipt = waiting
45
+ ? Promise.resolve().then(() => send(t('已排队,等待前序消息处理。'))).catch((error) => {
46
+ logger.warn?.('[dsh-im] failed to send queue receipt:', error);
47
+ })
48
+ : Promise.resolve();
49
+ return { completion: Promise.all([completion, receipt]).then(([result]) => result) };
50
+ };
51
+ const route = async () => {
52
+ signal?.throwIfAborted();
53
+ if (!steer || pendingInteraction()) return queue();
54
+ if (state.hasSeen(messageId)) return {};
55
+ await state.markSeen(messageId);
56
+ status.messagesReceived = (status.messagesReceived ?? 0) + 1;
57
+ status.lastMessageAt = new Date().toISOString();
58
+ signal?.throwIfAborted();
59
+ const submitted = await steerMessage(text, harness, state, key, {
60
+ signal, control, enhancement, pendingInteraction: pendingInteraction(),
61
+ });
62
+ if (!submitted) return queue(true);
63
+ // Submission is final even if its receipt fails.
64
+ await send(t('已提交补充指令,Agent 会在下一步读取。'));
65
+ await onSteered?.();
66
+ status.lastError = null;
67
+ return {};
68
+ };
69
+
70
+ const previous = this.#tails.get(key);
71
+ // Preserve synchronous queue reservation for the default/attachment path.
72
+ const routed = !previous && !steer
73
+ ? Promise.resolve(queue())
74
+ : (previous ?? Promise.resolve()).catch(() => undefined).then(route);
75
+ const tail = routed.then(() => undefined, () => undefined);
76
+ this.#tails.set(key, tail);
77
+ void tail.then(() => {
78
+ if (this.#tails.get(key) === tail) this.#tails.delete(key);
79
+ });
80
+ const task = routed.catch(async (error) => {
81
+ await report(error);
82
+ return {};
83
+ }).then(({ completion }) => completion).finally(() => {
84
+ acceptedMessageIds.delete(messageId);
85
+ this.#tasks.delete(task);
86
+ });
87
+ this.#tasks.add(task);
88
+ return task;
89
+ }
90
+
91
+ async whenIdle() {
92
+ await Promise.allSettled([...this.#tasks]);
93
+ }
94
+ }
@@ -0,0 +1,85 @@
1
+ import { t } from './i18n.mjs';
2
+ import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
3
+
4
+ const CLEAR_COMMAND = /^\/clear(?=$|\s)([\s\S]*)$/iu;
5
+ const CLEAR_USAGE = '用法:/clear(不带参数且不可附带图片或文件)';
6
+
7
+ function commandResult(message) {
8
+ return { handled: true, message, messages: [message] };
9
+ }
10
+
11
+ function clearErrorMessage(error) {
12
+ const code = error?.code ?? error?.failure?.code;
13
+ if (code === 'session-not-found') {
14
+ return t('当前聊天绑定的会话已不存在,未清空上下文;请先发送新消息开启会话。');
15
+ }
16
+ if (code === 'agent-busy' || code === 'pending-interaction') {
17
+ return t('当前会话正在生成回复或等待交互,请先完成交互或发送 /stop,再执行 /clear。');
18
+ }
19
+ if (code === 'cancelled' || error?.name === 'AbortError') {
20
+ return t('上下文清理已取消,当前会话未修改。');
21
+ }
22
+ if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
23
+ return t('工作区或机器人状态已发生变化,未清空当前会话,请重试。');
24
+ }
25
+ if (code === 'commands-unavailable') {
26
+ return t('当前 Host 不支持 /clear,当前会话未修改。');
27
+ }
28
+ return t('清空当前会话上下文失败,当前会话未修改,请稍后重试。');
29
+ }
30
+
31
+ /**
32
+ * Execute Host's argument-free /clear command without changing the dsh-im
33
+ * Session binding. Hosts that do not register /clear return undefined and
34
+ * are reported explicitly; the command never falls back to /new.
35
+ */
36
+ export async function runClearCommand(text, harness, state, conversationKey, options = {}) {
37
+ if (!isClearCommand(text)) return null;
38
+ const match = CLEAR_COMMAND.exec(text.trim());
39
+ if (match[1].trim() || options.hasImages || options.hasFiles) {
40
+ return commandResult(t(CLEAR_USAGE));
41
+ }
42
+ if (options.pendingInteraction || options.busy) {
43
+ return commandResult(t('当前会话正在生成回复或等待交互,请先完成交互或发送 /stop,再执行 /clear。'));
44
+ }
45
+ if (typeof state?.sessionFor !== 'function') {
46
+ return commandResult(t('当前机器人没有可用的会话状态,未清空当前会话。'));
47
+ }
48
+ const sessionId = state.sessionFor(conversationKey);
49
+ if (typeof sessionId !== 'string' || !sessionId) {
50
+ return commandResult(t('当前聊天还没有可清空的会话,请先发送一条消息。'));
51
+ }
52
+ try {
53
+ let execution;
54
+ if (typeof harness?.workspaceSession === 'function') {
55
+ const session = harness.workspaceSession(sessionId, conversationKey);
56
+ if (typeof session?.executeCommand !== 'function') {
57
+ return commandResult(t('当前 Host 不支持 /clear,当前会话未修改。'));
58
+ }
59
+ execution = await session.executeCommand('/clear', options);
60
+ } else {
61
+ if (typeof harness?.executeCommand !== 'function') {
62
+ return commandResult(t('当前 Host 不支持 /clear,当前会话未修改。'));
63
+ }
64
+ execution = await harness.executeCommand(sessionId, '/clear', options);
65
+ }
66
+ if (execution === undefined) {
67
+ return commandResult(t('当前 Host 不支持 /clear,当前会话未修改。'));
68
+ }
69
+ const result = execution?.result;
70
+ if (!result || !['success', 'error'].includes(result.kind)
71
+ || (result.text !== undefined && typeof result.text !== 'string')) {
72
+ return commandResult(t('清空当前会话上下文失败,当前会话未修改,请稍后重试。'));
73
+ }
74
+ if (result.kind === 'error') {
75
+ return commandResult(clearErrorMessage({ ...result, failure: result.failure ?? result }));
76
+ }
77
+ return commandResult(t('当前会话上下文已清空;Session 绑定和历史记录仍保留。'));
78
+ } catch (error) {
79
+ return commandResult(clearErrorMessage(error));
80
+ }
81
+ }
82
+
83
+ export function isClearCommand(text) {
84
+ return typeof text === 'string' && CLEAR_COMMAND.test(text.trim());
85
+ }
@@ -29,6 +29,9 @@ export const SHARED_COMMAND_CATALOG = Object.freeze([
29
29
  defineCatalogCommand('compact', '压缩当前会话的较早上下文', [
30
30
  '/compact 压缩当前会话的较早上下文',
31
31
  ]),
32
+ defineCatalogCommand('clear', '清空当前会话上下文并保留会话绑定', [
33
+ '/clear 清空当前会话上下文(保留 Session 绑定和历史记录)',
34
+ ]),
32
35
  defineCatalogCommand('history', '查看最近历史消息(仅私聊)', [
33
36
  '/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
34
37
  ], { privateOnly: true }),
@@ -1,5 +1,6 @@
1
1
  import { isBatchInputCommand } from './batch-input.mjs';
2
2
  import { isCompactCommand } from './compact-command.mjs';
3
+ import { isClearCommand } from './clear-command.mjs';
3
4
  import { isControlCommand } from './control-command.mjs';
4
5
  import { isHistoryCommand } from './history-command.mjs';
5
6
  import { isModelCommand } from './model-command.mjs';
@@ -20,6 +21,7 @@ export function isSharedLocalCommand(text, {
20
21
  const command = text.trim();
21
22
  if (!command) return false;
22
23
  if (isBatchInputCommand(command) || isHistoryCommand(command)) return true;
24
+ if (isClearCommand(command)) return true;
23
25
  if (!hasFiles && (
24
26
  isControlCommand(command)
25
27
  || isModelCommand(command)
@@ -62,6 +62,7 @@ export function extractConnectionEvidence(error) {
62
62
  ...(reasons.length > 1 ? { reasons } : {}),
63
63
  httpStatus: errors.find(e => e.httpStatus !== undefined)?.httpStatus,
64
64
  providerCode: errors.find(e => e.providerCode !== undefined)?.providerCode,
65
+ method: first('method'), transport: first('transport'),
65
66
  durationMs: first('durationMs'), timeoutMs: first('timeoutMs'), host: first('host'), truncated,
66
67
  }),
67
68
  };
@@ -36,6 +36,23 @@ export function isControlCommand(text) {
36
36
  return typeof text === 'string' && CONTROL_COMMAND.test(text.trim());
37
37
  }
38
38
 
39
+ // Shared by explicit /steer and ordinary messages configured to steer. A false
40
+ // result guarantees no submission; exceptions must never trigger a second ask.
41
+ export async function steerMessage(text, harness, state, key, {
42
+ signal, control, enhancement, pendingInteraction = false,
43
+ } = {}) {
44
+ if (pendingInteraction) return false;
45
+ const session = boundSession(harness, state, key);
46
+ if (!session) return false;
47
+ if (typeof session.steerActiveTurn !== 'function') {
48
+ throw new TypeError('Harness session does not support steering active turns');
49
+ }
50
+ const steering = enhancement
51
+ ? enhanceContextContent(text, enhancement.snapshot, enhancement.source)
52
+ : text;
53
+ return session.steerActiveTurn(steering, control, requestOptions(signal));
54
+ }
55
+
39
56
  export async function runControlCommand(text, harness, state, key, {
40
57
  signal,
41
58
  hasImages = false,
@@ -92,19 +109,11 @@ export async function runControlCommand(text, harness, state, key, {
92
109
  if (!session) {
93
110
  return commandResult(t('当前聊天没有绑定会话,无法补充指令。请先绑定会话。'));
94
111
  }
95
- if (typeof session.steerActiveTurn !== 'function') {
96
- throw new TypeError('Harness session does not support steering active turns');
97
- }
98
112
  // A mid-turn correction carries the same provenance as the message that
99
113
  // opened the turn, so a group member who steers is identified too.
100
- const steering = enhancement
101
- ? enhanceContextContent(instruction, enhancement.snapshot, enhancement.source)
102
- : instruction;
103
- const steered = await session.steerActiveTurn(
104
- steering,
105
- control,
106
- requestOptions(signal),
107
- );
114
+ const steered = await steerMessage(instruction, harness, state, key, {
115
+ signal, control, enhancement,
116
+ });
108
117
  return steered
109
118
  ? commandResult(t('已提交补充指令,Agent 会在下一步读取。'))
110
119
  : commandResult(t('任务已结束,没有正在运行的任务,无法补充指令。请直接发送消息开始新任务。'));
@@ -20,6 +20,12 @@ const REASONS = new Set([
20
20
  'unknown', 'multiple-causes', 'timeout', 'http-error', 'provider-rejected', 'invalid-response', 'ERR_PROXY_CONNECTION_FAILED', 'ERR_TLS_HANDSHAKE_TIMEOUT', 'ERR_SSL_TLSV1_ALERT_INTERNAL_ERROR', 'ERR_NETWORK_ACCESS_DENIED', 'AUTHENTICATIONFAILED', 'EAUTH', 'invalid-json', 'invalid-config', 'read-only',
21
21
  ]);
22
22
  const RESOURCES = new Set(['credential-store', 'account-config', 'account-state', 'workspace-config', 'workspace-directory']);
23
+ const RPC_METHODS = new Set([
24
+ 'host.describe', 'workspace.list', 'workspace.create', 'session.list', 'session.create',
25
+ 'session.history', 'session.prompt', 'session.rename', 'session.cancel', 'session.models',
26
+ 'session.permissions', 'session.selectModel', 'llm.models',
27
+ ]);
28
+ const TRANSPORTS = new Set(['host-api', 'http']);
23
29
  const CONFIG_FILES = new Set(['config.json', 'workspaces.json']);
24
30
  export const CONFIG_ISSUE_LABELS = Object.freeze({
25
31
  'expected-object': '应为 JSON 对象。',
@@ -44,7 +50,7 @@ const CONFIG_FIELD = /^(?:\$|version|accounts(?:\[\d{1,10}\](?:\.(?:accountId|ow
44
50
  export function normalizeDiagnosticDetails(value) {
45
51
  if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
46
52
  const result = {};
47
- for (const [field, allowed] of [['operation', OPERATIONS], ['stage', STAGES], ['reason', REASONS], ['resource', RESOURCES]]) {
53
+ for (const [field, allowed] of [['operation', OPERATIONS], ['stage', STAGES], ['reason', REASONS], ['resource', RESOURCES], ['method', RPC_METHODS], ['transport', TRANSPORTS]]) {
48
54
  if (allowed.has(value[field])) result[field] = value[field];
49
55
  }
50
56
  if (DIAGNOSTIC_CHANNELS.includes(value.channel)) result.channel = value.channel;
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { mkdir } from 'node:fs/promises';
4
4
  import { isAbsolute } from 'node:path';
5
+ import { performance } from 'node:perf_hooks';
5
6
 
6
7
  import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
7
8
  import { sameWorkspacePath } from './default-workspace.mjs';
@@ -1013,6 +1014,12 @@ export class HarnessClient {
1013
1014
  }
1014
1015
 
1015
1016
  async rpc(method, payload = {}, timeoutMs = 30_000, options = {}) {
1017
+ const startedAt = performance.now();
1018
+ const withDiagnostics = error => Object.assign(error, {
1019
+ durationMs: Math.round(performance.now() - startedAt),
1020
+ timeoutMs,
1021
+ transport: this.#apiProxy ? 'host-api' : 'http',
1022
+ });
1016
1023
  const rpcId = options.rpcId ?? `${this.#rpcIdPrefix}-${randomUUID()}`;
1017
1024
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
1018
1025
  const signal = options.signal
@@ -1051,24 +1058,24 @@ export class HarnessClient {
1051
1058
  // Preserve an explicit caller cancellation; it is control flow, not a
1052
1059
  // Harness availability diagnosis.
1053
1060
  if (options.signal?.aborted) throw error;
1054
- if (error instanceof HarnessTransportError) throw error;
1055
- throw new HarnessTransportError(
1061
+ if (error instanceof HarnessTransportError) throw withDiagnostics(error);
1062
+ throw withDiagnostics(new HarnessTransportError(
1056
1063
  timeoutSignal.aborted ? 'harness-timeout' : 'harness-connect-failed',
1057
1064
  method,
1058
1065
  { cause: error },
1059
- );
1066
+ ));
1060
1067
  }
1061
1068
  if (body?.type !== 'server-response' || body?.rpcId !== rpcId) {
1062
- throw new HarnessTransportError('harness-response-invalid', method, {
1069
+ throw withDiagnostics(new HarnessTransportError('harness-response-invalid', method, {
1063
1070
  cause: new Error(`Harness returned an invalid response for ${method}`),
1064
- });
1071
+ }));
1065
1072
  }
1066
1073
  if (!body.result || typeof body.result !== 'object' || typeof body.result.ok !== 'boolean') {
1067
- throw new HarnessTransportError('harness-response-invalid', method, {
1074
+ throw withDiagnostics(new HarnessTransportError('harness-response-invalid', method, {
1068
1075
  cause: new Error(`Harness returned an invalid result for ${method}`),
1069
- });
1076
+ }));
1070
1077
  }
1071
- if (!body.result?.ok) throw new HarnessRpcError(method, body.result?.error);
1078
+ if (!body.result?.ok) throw withDiagnostics(new HarnessRpcError(method, body.result?.error));
1072
1079
  return body.result.value;
1073
1080
  }
1074
1081
 
@@ -47,6 +47,9 @@ export default Object.freeze({
47
47
  "暂未识别具体原因,请复制诊断信息,并查看相同参考号的 DSH 日志。": "The underlying cause is unknown. Copy the diagnostics and find the matching reference in the DSH logs.",
48
48
  "多个原因": "Multiple causes",
49
49
  "服务域名": "Service host",
50
+ "RPC 方法": "RPC method",
51
+ "调用方式": "Transport",
52
+ "宿主 API(进程内)": "Host API (in-process)",
50
53
  "请求耗时(毫秒)": "Request duration (ms)",
51
54
  "超时阈值(毫秒)": "Timeout (ms)",
52
55
  "Node 版本": "Node version",
@@ -259,10 +259,10 @@ export default {
259
259
  '/presetlist or /presets List available Agent Presets',
260
260
  '🎮 任务控制': '🎮 Task controls',
261
261
  '/steer 指令 给 Agent 补充指令': '/steer INSTRUCTION Steer the Agent',
262
- '**📋 卡片功能**\n\n1. 会话下拉 — 切换当前绑定会话\n2. 工作区下拉 — 切换工作区\n3. 🤖 预设下拉 — 切换 Agent 预设\n4. 🧠 模型下拉 — 切换模型\n5. 🆕 新会话 — 开启全新会话\n6. 📋 会话/关注 — 查看/绑定会话,管理关注\n7. ⏹ 停止 — 停止当前任务\n8. 📐 压缩 — 压缩当前会话上下文\n9. 补充指令 — 给 Agent 发送指令\n10. 🗄 归档切换 — 显示/隐藏归档会话\n11. 📊 状态 — 查看系统连接状态\n12. 📖 帮助 — 查看本帮助':
263
- '**📋 Card features**\n\n1. Session dropdown — switch the bound session\n2. Workspace dropdown — switch workspace\n3. 🤖 Preset dropdown — switch Agent Preset\n4. 🧠 Model dropdown — switch model\n5. 🆕 New session — start fresh\n6. 📋 Sessions/watches — view or bind sessions and manage watches\n7. ⏹ Stop — stop the current task\n8. 📐 Compact — compact the current session context\n9. Steer task — send an instruction to the Agent\n10. 🗄 Archived toggle — show or hide archived sessions\n11. 📊 Status — view connection status\n12. 📖 Help — view this help',
264
- '**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话\n`/sessionlist --limit N` 或 `/sessions --limit N` — 仅列出当前工作区前 N 个会话\n`/workspace 工作区序号或绝对路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` 或 `/presets` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级\n`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级\n`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型\n`/batch` — 开启批量输入(仅私聊,最多 10 条文字)\n`/send` — 提交当前批次\n`/cancel` — 取消当前批次\n`/repair` — 补全飞书权限与卡片回调':
265
- '**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` or `/sessions [workspace]` — list sessions\n`/sessionlist --limit N` or `/sessions --limit N` — list only the first N sessions in the current workspace\n`/workspace <workspace index or absolute path>` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` or `/presets` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/reasoninglist` or `/reasonings` — list reasoning efforts for the current model\n`/reasoning [index, effort ID, or --default]` — show or switch reasoning effort\n`/model [index or full model ID] [reasoning effort ID]` — show or switch the current Session model\n`/batch` — start batch input (direct messages only, up to 10 text messages)\n`/send` — submit the current batch\n`/cancel` — cancel the current batch\n`/repair` — complete Feishu permissions and the card callback',
262
+ '**📋 卡片功能**\n\n1. 会话下拉 — 切换当前绑定会话\n2. 工作区下拉 — 切换工作区\n3. 🤖 预设下拉 — 切换 Agent 预设\n4. 🧠 模型下拉 — 切换模型\n5. 🆕 新会话 — 开启全新会话\n6. 📋 会话/关注 — 查看/绑定会话,管理关注\n7. ⏹ 停止 — 停止当前任务\n8. 📐 压缩 — 压缩当前会话上下文\n9. 🧹 清空上下文 — 清空上下文并保留绑定和历史记录\n10. 补充指令 — 给 Agent 发送指令\n11. 🗄 归档切换 — 显示/隐藏归档会话\n12. 📊 状态 — 查看系统连接状态\n13. 📖 帮助 — 查看本帮助':
263
+ '**📋 Card features**\n\n1. Session dropdown — switch the bound session\n2. Workspace dropdown — switch workspace\n3. 🤖 Preset dropdown — switch Agent Preset\n4. 🧠 Model dropdown — switch model\n5. 🆕 New session — start fresh\n6. 📋 Sessions/watches — view or bind sessions and manage watches\n7. ⏹ Stop — stop the current task\n8. 📐 Compact — compact the current session context\n9. 🧹 Clear context — clear context while keeping the binding and history\n10. Steer task — send an instruction to the Agent\n11. 🗄 Archived toggle — show or hide archived sessions\n12. 📊 Status — view connection status\n13. 📖 Help — view this help',
264
+ '**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话\n`/sessionlist --limit N` 或 `/sessions --limit N` — 仅列出当前工作区前 N 个会话\n`/workspace 工作区序号或绝对路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/clear` — 清空上下文并保留 Session 绑定和历史记录\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` 或 `/presets` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级\n`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级\n`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型\n`/batch` — 开启批量输入(仅私聊,最多 10 条文字)\n`/send` — 提交当前批次\n`/cancel` — 取消当前批次\n`/repair` — 补全飞书权限与卡片回调':
265
+ '**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` or `/sessions [workspace]` — list sessions\n`/sessionlist --limit N` or `/sessions --limit N` — list only the first N sessions in the current workspace\n`/workspace <workspace index or absolute path>` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/clear` — clear context while keeping the Session binding and history\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` or `/presets` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/reasoninglist` or `/reasonings` — list reasoning efforts for the current model\n`/reasoning [index, effort ID, or --default]` — show or switch reasoning effort\n`/model [index or full model ID] [reasoning effort ID]` — show or switch the current Session model\n`/batch` — start batch input (direct messages only, up to 10 text messages)\n`/send` — submit the current batch\n`/cancel` — cancel the current batch\n`/repair` — complete Feishu permissions and the card callback',
266
266
  '**💡 数字兜底**\n回复数字快速操作:\n**1**工作区列表 · **2**新会话 · **3**会话/关注\n**4**状态 · **5**补全权限 · **6**帮助':
267
267
  '**💡 Number fallback**\nReply with a number for a quick action:\n**1** Workspace list · **2** New session · **3** Sessions/watches\n**4** Status · **5** Complete permissions · **6** Help',
268
268
  '从下方下拉选择补充指令;最后一项可自定义输入。':
@@ -319,6 +319,8 @@ export default {
319
319
  '/watch ID 或序号 关注会话(完成后推送)':
320
320
  '/watch ID or index Watch a session (push when done)',
321
321
  '/compact 压缩上下文': '/compact Compact the context',
322
+ '🧹 清空上下文': '🧹 Clear context',
323
+ '清空当前会话上下文并保留绑定和历史记录': 'Clear the current session context while keeping the binding and history',
322
324
  '/workspace 绝对路径 切换工作区': '/workspace <absolute path> Switch workspace',
323
325
  '/presetlist 列出可用 Agent Preset': '/presetlist List available Agent Presets',
324
326
  '任务完成会自动推送,回复数字或点按钮取消关注:':
@@ -29,7 +29,8 @@ export default {
29
29
  '发送 /m 打开助手中心,点击按钮或回复当前列表的数字选择。': 'Send /m to open the assistant menu. Click a button or reply with a number from the current list.',
30
30
  '设置:会话、工作区、模式/预设、模型。': 'Settings: session, workspace, mode / preset, model.',
31
31
  '会话:新会话、会话列表、归档显示切换。': 'Sessions: new session, session list, show or hide archived sessions.',
32
- '任务:停止、压缩、快捷或自定义补充指令。': 'Tasks: stop, compact, quick or custom steering instructions.',
32
+ '任务:停止、压缩、清空上下文、快捷或自定义补充指令。':
33
+ 'Tasks: stop, compact, clear context, quick or custom steering instructions.',
33
34
  '信息:状态、帮助。': 'Information: status, help.',
34
35
  '菜单有效期为 15 分钟;发送普通消息会退出数字选择。': 'Menus expire after 15 minutes. Sending an ordinary message exits number selection.',
35
36
  '回答问题、审批和批量输入时,数字优先用于当前交互。': 'During questions, approvals and batch input, numbers belong to that interaction.',
@@ -244,6 +244,32 @@ export default {
244
244
  '当前 Harness 未注册 /compact 命令,请确认上下文压缩组件已启用。':
245
245
  'The current Harness has not registered the /compact command; please make sure the context compaction component is enabled.',
246
246
 
247
+ // clear-command.mjs
248
+ '/clear 清空当前会话上下文(保留 Session 绑定和历史记录)':
249
+ '/clear Clear the current session context (keep the Session binding and history)',
250
+ '清空当前会话上下文并保留会话绑定':
251
+ 'Clear the current session context and keep the Session binding',
252
+ '用法:/clear(不带参数且不可附带图片或文件)':
253
+ 'Usage: /clear (no arguments, images or files)',
254
+ '当前聊天绑定的会话已不存在,未清空上下文;请先发送新消息开启会话。':
255
+ 'The Session bound to this chat no longer exists; the context was not cleared. Send a new message to start a Session first.',
256
+ '当前会话正在生成回复或等待交互,请先完成交互或发送 /stop,再执行 /clear。':
257
+ 'The current Session is generating a reply or waiting for an interaction. Finish it or send /stop before running /clear.',
258
+ '上下文清理已取消,当前会话未修改。':
259
+ 'Context clearing was cancelled; the current Session was not changed.',
260
+ '工作区或机器人状态已发生变化,未清空当前会话,请重试。':
261
+ 'The workspace or bot state changed, so the current Session was not cleared. Please try again.',
262
+ '当前 Host 不支持 /clear,当前会话未修改。':
263
+ 'The current Host does not support /clear; the current Session was not changed.',
264
+ '清空当前会话上下文失败,当前会话未修改,请稍后重试。':
265
+ 'Failed to clear the current Session context; the Session was not changed. Please try again later.',
266
+ '当前机器人没有可用的会话状态,未清空当前会话。':
267
+ 'This bot has no available Session state; the current Session was not cleared.',
268
+ '当前聊天还没有可清空的会话,请先发送一条消息。':
269
+ 'This chat has no Session to clear yet; please send a message first.',
270
+ '当前会话上下文已清空;Session 绑定和历史记录仍保留。':
271
+ 'The current Session context was cleared; its binding and history were retained.',
272
+
247
273
  // control-command.mjs
248
274
  '用法:/stop(不带参数)': 'Usage: /stop (no arguments)',
249
275
  '用法:/version(不带参数)': 'Usage: /version (no arguments)',
@@ -258,6 +284,7 @@ export default {
258
284
  'No session is bound to this chat, so an additional instruction cannot be given. Bind a session first.',
259
285
  '任务已结束,没有正在运行的任务,无法补充指令。请直接发送消息开始新任务。':
260
286
  'The task has ended and there is no running task to steer. Send a message to start a new task.',
287
+ '已排队,等待前序消息处理。': 'Queued. Waiting for the preceding messages to finish.',
261
288
  '已提交补充指令,Agent 会在下一步读取。':
262
289
  'Additional instruction submitted; the Agent will read it at the next step.',
263
290
  };