evolclaw 2.7.2 → 2.8.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.
@@ -204,7 +204,7 @@ export class AgentRunner {
204
204
  // 没有交互上下文(无渠道适配器),回退到纯文本
205
205
  const permCtx = this.permissionContexts.get(sessionId);
206
206
  if (!permCtx?.adapter?.sendInteraction || !permCtx?.channelId) {
207
- return this.handleAskUserQuestionFallback(input, questions);
207
+ return this.handleAskUserQuestionFallback(sessionId, input, questions);
208
208
  }
209
209
  const answers = {};
210
210
  // 从 permCtx 构造 per-session 的发送函数,避免全局 sendPromptFn 被其他 channel 实例覆盖
@@ -307,20 +307,43 @@ export class AgentRunner {
307
307
  return { behavior: 'allow', updatedInput, decisionClassification: 'user_temporary' };
308
308
  }
309
309
  /**
310
- * AskUserQuestion 纯文本 fallback:发送文本格式的问题,直接 allow
310
+ * AskUserQuestion 纯文本 fallback:发送选项列表,等待用户通过 /ask 命令选择
311
+ * 注册到 interactionRouter,用户回复 /ask 1 或 /ask 自定义内容
311
312
  */
312
- async handleAskUserQuestionFallback(input, questions) {
313
- // 自动选择每个问题的第一个选项
313
+ async handleAskUserQuestionFallback(sessionId, input, questions) {
314
+ const permCtx = this.permissionContexts.get(sessionId);
315
+ const sendPrompt = permCtx?.adapter && permCtx?.channelId
316
+ ? async (text) => permCtx.adapter.sendText(permCtx.channelId, text, permCtx.replyContext)
317
+ : this.sendPromptFn;
314
318
  const answers = {};
315
319
  if (questions?.length) {
316
- const lines = questions.map(q => {
317
- const firstLabel = q.options[0]?.label || '';
318
- answers[q.question] = firstLabel;
320
+ for (const q of questions) {
319
321
  const optText = q.options.map((o, i) => ` ${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n');
320
- return `${q.question}\n${optText}\n 自动选择:${firstLabel}`;
321
- });
322
- if (this.sendPromptFn) {
323
- await this.sendPromptFn(`💬 以下问题已自动选择第一项:\n\n${lines.join('\n\n')}`);
322
+ const prompt = `💬 ${q.question}\n${optText}\n\n回复 /ask <数字> 选择,或 /ask <自定义内容>`;
323
+ if (sendPrompt && permCtx?.interactionRouter) {
324
+ await sendPrompt(prompt);
325
+ const requestId = `ask-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
326
+ const answer = await new Promise((resolve) => {
327
+ permCtx.interactionRouter.register(requestId, sessionId, (action) => {
328
+ const num = parseInt(action.trim(), 10);
329
+ if (num >= 1 && num <= q.options.length) {
330
+ resolve(q.options[num - 1].label);
331
+ }
332
+ else {
333
+ resolve(action.trim());
334
+ }
335
+ }, { timeoutMs: 120_000, onTimeout: () => resolve(q.options[0]?.label || '') });
336
+ });
337
+ answers[q.question] = answer;
338
+ }
339
+ else {
340
+ // 无交互能力,自动选第一项
341
+ const firstLabel = q.options[0]?.label || '';
342
+ answers[q.question] = firstLabel;
343
+ if (sendPrompt) {
344
+ await sendPrompt(`${prompt}\n → 自动选择:${firstLabel}`);
345
+ }
346
+ }
324
347
  }
325
348
  }
326
349
  const updatedInput = { ...input, answers };
@@ -331,51 +354,71 @@ export class AgentRunner {
331
354
  */
332
355
  async handleExitPlanMode(sessionId, input, options) {
333
356
  const permCtx = this.permissionContexts.get(sessionId);
334
- // 从 permCtx 构造 per-session 的发送函数,避免全局 sendPromptFn 被其他 channel 实例覆盖
335
357
  const sendPrompt = permCtx?.adapter && permCtx?.channelId
336
358
  ? async (text) => permCtx.adapter.sendText(permCtx.channelId, text, permCtx.replyContext)
337
359
  : this.sendPromptFn;
338
- // 无交互上下文,直接 allow(防御性兜底)
339
- if (!permCtx?.adapter?.sendInteraction || !permCtx?.channelId || !sendPrompt) {
360
+ // 无任何交互能力,直接 allow
361
+ if (!permCtx?.channelId || !sendPrompt) {
340
362
  return { behavior: 'allow', updatedInput: input, decisionClassification: 'user_temporary' };
341
363
  }
342
- const requestId = `plan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
343
- const interaction = {
344
- type: 'interaction',
345
- id: requestId,
346
- kind: {
347
- kind: 'action',
348
- title: '📋 计划审批',
349
- body: 'AI 已完成规划,等待审批。\n请查看以上计划内容后决定。',
350
- buttons: [
351
- { key: 'approve', label: '✅ 批准执行', style: 'primary' },
352
- { key: 'reject', label: '❌ 拒绝', style: 'danger' },
353
- ],
354
- },
355
- channelId: permCtx.channelId,
356
- sessionId,
357
- };
364
+ // 尝试发送交互卡片
358
365
  let cardSent = false;
359
- try {
360
- const result = await permCtx.adapter.sendInteraction(permCtx.channelId, interaction, permCtx.replyContext);
361
- cardSent = !!result;
362
- }
363
- catch (err) {
364
- logger.warn('[AgentRunner] ExitPlanMode card send failed:', err);
365
- }
366
- if (!cardSent) {
367
- await sendPrompt('📋 计划审批\nAI 已完成规划,等待审批。\n回复 /plan approve 批准执行 | /plan reject 拒绝');
366
+ if (permCtx.adapter?.sendInteraction) {
367
+ const requestId = `plan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
368
+ const interaction = {
369
+ type: 'interaction',
370
+ id: requestId,
371
+ kind: {
372
+ kind: 'action',
373
+ title: '📋 计划审批',
374
+ body: 'AI 已完成规划,等待审批。\n请查看以上计划内容后决定。',
375
+ buttons: [
376
+ { key: 'approve', label: '✅ 批准执行', style: 'primary' },
377
+ { key: 'reject', label: '❌ 拒绝', style: 'danger' },
378
+ ],
379
+ },
380
+ channelId: permCtx.channelId,
381
+ sessionId,
382
+ };
383
+ try {
384
+ const result = await permCtx.adapter.sendInteraction(permCtx.channelId, interaction, permCtx.replyContext);
385
+ cardSent = !!result;
386
+ }
387
+ catch (err) {
388
+ logger.warn('[AgentRunner] ExitPlanMode card send failed:', err);
389
+ }
390
+ if (cardSent) {
391
+ return new Promise((resolve) => {
392
+ permCtx.interactionRouter?.register(requestId, sessionId, (action) => {
393
+ if (action === 'approve') {
394
+ resolve({ behavior: 'allow', updatedInput: input, decisionClassification: 'user_temporary' });
395
+ }
396
+ else {
397
+ resolve({ behavior: 'deny', message: '用户拒绝了计划', decisionClassification: 'user_reject' });
398
+ }
399
+ });
400
+ });
401
+ }
368
402
  }
369
- return new Promise((resolve) => {
370
- permCtx?.interactionRouter?.register(requestId, sessionId, (action) => {
371
- if (action === 'approve') {
372
- resolve({ behavior: 'allow', updatedInput: input, decisionClassification: 'user_temporary' });
373
- }
374
- else {
375
- resolve({ behavior: 'deny', message: '用户拒绝了计划', decisionClassification: 'user_reject' });
376
- }
403
+ // 文本 fallback:注册到 interactionRouter,等待用户 /ask 回复
404
+ if (permCtx.interactionRouter) {
405
+ await sendPrompt('📋 计划审批\nAI 已完成规划,等待审批。\n\n 1. 批准执行\n 2. 拒绝\n\n回复 /ask 1 批准,/ask 2 拒绝:');
406
+ const requestId = `plan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
407
+ return new Promise((resolve) => {
408
+ permCtx.interactionRouter.register(requestId, sessionId, (action) => {
409
+ const trimmed = action.trim();
410
+ if (trimmed === '2' || trimmed.toLowerCase() === 'reject' || trimmed === '拒绝') {
411
+ resolve({ behavior: 'deny', message: '用户拒绝了计划', decisionClassification: 'user_reject' });
412
+ }
413
+ else {
414
+ resolve({ behavior: 'allow', updatedInput: input, decisionClassification: 'user_temporary' });
415
+ }
416
+ }, { timeoutMs: 300_000, onTimeout: () => resolve({ behavior: 'allow', updatedInput: input, decisionClassification: 'user_temporary' }) });
377
417
  });
378
- });
418
+ }
419
+ // 无交互能力,发提示后直接 allow
420
+ await sendPrompt('📋 计划审批\nAI 已完成规划,自动批准执行。');
421
+ return { behavior: 'allow', updatedInput: input, decisionClassification: 'user_temporary' };
379
422
  }
380
423
  /**
381
424
  * SDK 原始事件 → 标准 AgentEvent 转换
@@ -131,8 +131,27 @@ export class AUNChannel {
131
131
  .replace(/[ \t]+/g, ' ')
132
132
  .trim();
133
133
  }
134
- buildGroupReplyContext(taskId, senderAid) {
135
- const replyContext = {};
134
+ extractMentionAids(mentions) {
135
+ const aids = [];
136
+ for (const m of mentions) {
137
+ if (typeof m === 'string')
138
+ aids.push(m);
139
+ else if (m && typeof m === 'object' && typeof m.aid === 'string')
140
+ aids.push(m.aid);
141
+ }
142
+ return aids;
143
+ }
144
+ hasMentionAll(mentions) {
145
+ for (const m of mentions) {
146
+ if (m === 'all')
147
+ return true;
148
+ if (m && typeof m === 'object' && m.scope === 'all')
149
+ return true;
150
+ }
151
+ return false;
152
+ }
153
+ buildGroupReplyContext(taskId, senderAid, encrypted) {
154
+ const replyContext = { metadata: { encrypted } };
136
155
  if (taskId)
137
156
  replyContext.threadId = taskId;
138
157
  replyContext.peerId = senderAid;
@@ -167,6 +186,11 @@ export class AUNChannel {
167
186
  peerE2ee = new Map();
168
187
  static E2EE_PROBE_TTL = 10 * 60 * 1000; // 10min
169
188
  plaintextRecv = 0;
189
+ sessionModeResolver;
190
+ static PROACTIVE_ALLOW_TYPES = new Set([
191
+ 'text', 'quote', 'image', 'video', 'voice', 'file', 'json',
192
+ 'merge', 'link', 'location', 'personal_card',
193
+ ]);
170
194
  // Reconnect state (TS-layer fallback, on top of SDK auto_reconnect)
171
195
  intentionalDisconnect = false;
172
196
  reconnectAttempt = 0;
@@ -555,16 +579,10 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
555
579
  logger.debug(`[AUN] P2P dropped: echo from self (from=${fromAid} mid=${messageId})`);
556
580
  return;
557
581
  }
558
- // E2EE 能力探测:收到加密消息则标记对端支持,明文则计数审计
582
+ // 记录入站消息加密状态,透传到出站 ReplyContext
559
583
  const msgEncrypted = !!(msg.e2ee);
560
- if (fromAid) {
561
- if (msgEncrypted) {
562
- this.peerE2ee.set(fromAid, { ok: true, ts: Date.now() });
563
- }
564
- else {
565
- this.plaintextRecv++;
566
- }
567
- }
584
+ if (!msgEncrypted)
585
+ this.plaintextRecv++;
568
586
  // Detect @mentions
569
587
  const mentions = [];
570
588
  if (this._aid && text.includes(`@${this._aid}`)) {
@@ -600,7 +618,10 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
600
618
  const peerInfo = await this.fetchPeerInfo(fromAid);
601
619
  const shortAid = this.getShortAid(fromAid);
602
620
  const displayName = peerInfo.name || shortAid;
603
- logger.info(`[AUN] P2P dispatched: from=${shortAid}(${displayName}) mid=${messageId} text=${finalText.slice(0, 60)}`);
621
+ logger.info(`[AUN] P2P dispatched: from=${shortAid}(${displayName}) mid=${messageId} encrypt=${msgEncrypted} text=${finalText.slice(0, 60)}`);
622
+ const replyContext = { metadata: { encrypted: msgEncrypted } };
623
+ if (taskId)
624
+ replyContext.threadId = taskId;
604
625
  this.dispatchMessage({
605
626
  channelId: chatId,
606
627
  userId: fromAid,
@@ -612,6 +633,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
612
633
  mentions,
613
634
  peerName: displayName || undefined,
614
635
  peerType: peerInfo.type || 'unknown',
636
+ replyContext,
615
637
  });
616
638
  }
617
639
  async handleIncomingGroupMessage(data) {
@@ -640,16 +662,33 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
640
662
  logger.debug(`[AUN] Group dropped: own message (group=${groupId} mid=${messageId})`);
641
663
  return;
642
664
  }
643
- // E2EE 能力探测:收到加密群消息则标记发送者支持
644
- const msgEncrypted = !!(msg.e2ee);
645
- if (senderAid) {
646
- if (msgEncrypted) {
647
- this.peerE2ee.set(senderAid, { ok: true, ts: Date.now() });
648
- }
649
- else {
650
- this.plaintextRecv++;
665
+ // ── proactive 模式入站白名单 ──
666
+ if (this.sessionModeResolver) {
667
+ const sessionMode = await this.sessionModeResolver(groupId).catch(() => undefined);
668
+ if (sessionMode === 'proactive') {
669
+ const payloadObj = (payload && typeof payload === 'object') ? payload : null;
670
+ const payloadType = payloadObj?.type ?? '';
671
+ if (!AUNChannel.PROACTIVE_ALLOW_TYPES.has(payloadType)) {
672
+ this.acknowledgeImmediately(messageId, seq);
673
+ logger.debug(`[AUN] Group dropped (proactive deny): type=${payloadType} group=${groupId} sender=${senderAid} mid=${messageId}`);
674
+ return;
675
+ }
676
+ const rawText = typeof payloadObj?.text === 'string' ? payloadObj.text : '';
677
+ const rawMentions = Array.isArray(payloadObj?.mentions) ? payloadObj.mentions : [];
678
+ const mentionAids = this.extractMentionAids(rawMentions);
679
+ const mentionsSelf = !!this._aid && (this.hasExplicitMention(rawText, this._aid) || mentionAids.includes(this._aid));
680
+ const mentionsAll = this.hasExplicitMention(rawText, 'all') || this.hasMentionAll(rawMentions);
681
+ if (!mentionsSelf && !mentionsAll) {
682
+ this.acknowledgeImmediately(messageId, seq);
683
+ logger.debug(`[AUN] Group dropped (proactive whitelist): type=${payloadType} group=${groupId} sender=${senderAid} mid=${messageId}`);
684
+ return;
685
+ }
651
686
  }
652
687
  }
688
+ // 记录入站消息加密状态,透传到出站 ReplyContext
689
+ const msgEncrypted = !!(msg.e2ee);
690
+ if (!msgEncrypted)
691
+ this.plaintextRecv++;
653
692
  // dispatch_mode from server tells agent how to work in this group
654
693
  const dispatchMode = msg.dispatch_mode ?? payload?.dispatch_mode ?? 'mention';
655
694
  const mentionedSelf = this._aid
@@ -712,7 +751,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
712
751
  seq,
713
752
  taskId,
714
753
  mentions,
715
- replyContext: this.buildGroupReplyContext(taskId, senderAid),
754
+ replyContext: this.buildGroupReplyContext(taskId, senderAid, msgEncrypted),
716
755
  });
717
756
  }
718
757
  dispatchMessage(event) {
@@ -812,6 +851,9 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
812
851
  onMessage(handler) {
813
852
  this.messageHandler = handler;
814
853
  }
854
+ setSessionModeResolver(resolver) {
855
+ this.sessionModeResolver = resolver;
856
+ }
815
857
  onRecall(handler) {
816
858
  this.recallHandler = handler;
817
859
  }
@@ -851,17 +893,25 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
851
893
  payload.chat_id = channelId;
852
894
  }
853
895
  const encryptTarget = isGroup ? channelId : targetAid;
854
- const encrypt = this.shouldEncrypt(encryptTarget);
896
+ const encrypt = context?.metadata?.encrypted != null
897
+ ? !!(context.metadata.encrypted)
898
+ : this.shouldEncrypt(encryptTarget);
855
899
  const params = { payload, encrypt };
856
900
  try {
857
901
  if (isGroup) {
858
902
  params.group_id = channelId;
859
903
  const result = await this.callAndTrace('group.send', params);
860
904
  if (!result || !result.message_id) {
861
- logger.warn(`[AUN] group.send returned no message_id: ${JSON.stringify(result)}`);
905
+ const dispatchStatus = result?.message_dispatch?.status;
906
+ if (dispatchStatus === 'debounced' || dispatchStatus === 'dispatched') {
907
+ logger.info(`[AUN] group.send ok (${dispatchStatus}): group=${channelId} encrypt=${encrypt} text=${finalText.slice(0, 60)}`);
908
+ }
909
+ else {
910
+ logger.warn(`[AUN] group.send returned no message_id: ${JSON.stringify(result)}`);
911
+ }
862
912
  }
863
913
  else {
864
- logger.info(`[AUN] group.send ok: group=${channelId} mid=${result.message_id} text=${finalText.slice(0, 60)}`);
914
+ logger.info(`[AUN] group.send ok: group=${channelId} mid=${result.message_id} encrypt=${encrypt} text=${finalText.slice(0, 60)}`);
865
915
  }
866
916
  }
867
917
  else {
@@ -871,7 +921,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
871
921
  logger.warn(`[AUN] message.send returned no message_id: ${JSON.stringify(result)}`);
872
922
  }
873
923
  else {
874
- logger.info(`[AUN] message.send ok: to=${targetAid} mid=${result.message_id} text=${finalText.slice(0, 60)}`);
924
+ logger.info(`[AUN] message.send ok: to=${targetAid} mid=${result.message_id} encrypt=${encrypt} text=${finalText.slice(0, 60)}`);
875
925
  }
876
926
  }
877
927
  }
@@ -917,7 +967,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
917
967
  * selector 使用 context: { type: 'task', id: taskId }
918
968
  * 存储键:group_id/peer_aid + sender_aid + context.type + context.id
919
969
  */
920
- async sendThought(channelId, taskId, payload) {
970
+ async sendThought(channelId, taskId, payload, context) {
921
971
  if (!this.connected || !this.client)
922
972
  return;
923
973
  if (!taskId)
@@ -925,22 +975,25 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
925
975
  // Multi-instance routing
926
976
  const colonIdx = channelId.indexOf(':');
927
977
  const targetId = colonIdx > 0 ? channelId.substring(0, colonIdx) : channelId;
978
+ const encrypt = context?.metadata?.encrypted != null
979
+ ? !!(context.metadata.encrypted)
980
+ : this.shouldEncrypt(targetId);
928
981
  const params = {
929
982
  context: { type: 'task', id: taskId },
930
983
  payload,
931
- encrypt: true,
984
+ encrypt,
932
985
  };
933
986
  try {
934
987
  const stage = payload?.stage ?? 'unknown';
935
988
  if (this.isGroupId(channelId)) {
936
989
  params.group_id = targetId;
937
990
  await this.callAndTrace('group.thought.put', params);
938
- logger.info(`[AUN] thought.put ok group=${targetId} task=${taskId} stage=${stage}`);
991
+ logger.info(`[AUN] thought.put ok group=${targetId} task=${taskId} stage=${stage} encrypt=${encrypt}`);
939
992
  }
940
993
  else {
941
994
  params.to = targetId;
942
995
  await this.callAndTrace('message.thought.put', params);
943
- logger.info(`[AUN] thought.put ok p2p=${targetId} task=${taskId} stage=${stage}`);
996
+ logger.info(`[AUN] thought.put ok p2p=${targetId} task=${taskId} stage=${stage} encrypt=${encrypt}`);
944
997
  }
945
998
  }
946
999
  catch (e) {
@@ -1035,7 +1088,9 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1035
1088
  filePayload.chat_id = channelId;
1036
1089
  }
1037
1090
  const encryptTarget = isGroup ? channelId : fileTargetAid;
1038
- const encrypt = this.shouldEncrypt(encryptTarget);
1091
+ const encrypt = context?.metadata?.encrypted != null
1092
+ ? !!(context.metadata.encrypted)
1093
+ : this.shouldEncrypt(encryptTarget);
1039
1094
  const params = { payload: filePayload, encrypt };
1040
1095
  try {
1041
1096
  if (isGroup) {
@@ -1127,7 +1182,9 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1127
1182
  payload.chat_id = channelId;
1128
1183
  }
1129
1184
  const encryptTarget = isGroup ? channelId : statusTargetAid;
1130
- const encrypt = this.shouldEncrypt(encryptTarget);
1185
+ const encrypt = context?.metadata?.encrypted != null
1186
+ ? !!(context.metadata.encrypted)
1187
+ : this.shouldEncrypt(encryptTarget);
1131
1188
  const params = { payload, encrypt };
1132
1189
  const sendWithFallback = (method) => {
1133
1190
  this.client.call(method, params).catch(e => {
@@ -1154,7 +1211,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1154
1211
  this.trace('OUT', 'message.send.status', params);
1155
1212
  sendWithFallback('message.send');
1156
1213
  }
1157
- logger.info(`[AUN] task.${status} task=${taskId} session=${sessionId} target=${channelId}`);
1214
+ logger.info(`[AUN] task.${status} task=${taskId} session=${sessionId} encrypt=${encrypt} target=${channelId}`);
1158
1215
  }
1159
1216
  sendCustomPayload(channelId, payload) {
1160
1217
  if (!this.client || !this.connected)
@@ -1367,7 +1424,7 @@ export class AUNChannelPlugin {
1367
1424
  sendCustomPayload: (id, payload) => channel.sendCustomPayload(id, payload),
1368
1425
  uploadAgentMd: (content) => channel.uploadAgentMd(content),
1369
1426
  downloadAgentMd: (aid) => channel.downloadAgentMd(aid),
1370
- putThought: (id, taskId, payload) => channel.sendThought(id, taskId, payload),
1427
+ putThought: (id, taskId, payload, context) => channel.sendThought(id, taskId, payload, context),
1371
1428
  _selfAid: () => channel.getStatus().aid,
1372
1429
  _selfName: () => channel.getSelfName(),
1373
1430
  };
@@ -104,7 +104,7 @@ function formatIdleTime(ms) {
104
104
  return '刚刚';
105
105
  }
106
106
  // 支持的命令列表
107
- const commands = ['/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart', '/model', '/effort', '/agent', '/slist', '/session', '/rename', '/stop', '/clear', '/compact', '/repair', '/safe', '/fork', '/del', '/perm', '/file', '/check', '/rewind', '/activity', '/aid', '/agentmd', '/chatmode'];
107
+ const commands = ['/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart', '/model', '/effort', '/agent', '/slist', '/session', '/rename', '/stop', '/clear', '/compact', '/repair', '/safe', '/fork', '/del', '/perm', '/file', '/check', '/rewind', '/activity', '/aid', '/agentmd', '/chatmode', '/ask'];
108
108
  // 命令别名映射
109
109
  const aliases = {
110
110
  '/p': '/project',
@@ -113,7 +113,7 @@ const aliases = {
113
113
  '/rw': '/rewind'
114
114
  };
115
115
  // 命令快速路径前缀(所有命令都不进入消息队列)
116
- const quickCommandPrefixes = ['/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart', '/model', '/effort', '/agent', '/slist', '/session', '/rename', '/repair', '/fork', '/stop', '/clear', '/compact', '/safe', '/del', '/perm', '/file', '/check', '/p ', '/s ', '/name', '/rewind', '/rw', '/rw ', '/activity', '/chatmode', '/aid', '/agentmd'];
116
+ const quickCommandPrefixes = ['/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart', '/model', '/effort', '/agent', '/slist', '/session', '/rename', '/repair', '/fork', '/stop', '/clear', '/compact', '/safe', '/del', '/perm', '/file', '/check', '/p ', '/s ', '/name', '/rewind', '/rw', '/rw ', '/activity', '/chatmode', '/aid', '/agentmd', '/ask'];
117
117
  export class CommandHandler {
118
118
  sessionManager;
119
119
  config;
@@ -401,6 +401,10 @@ export class CommandHandler {
401
401
  { value: 'high', label: 'High' },
402
402
  { value: 'max', label: 'Max' },
403
403
  ] } },
404
+ { cmd: '/chatmode', label: '切换会话模式', desc: '控制 Agent 主动性(被动响应或主动推进)', next: { type: 'select', items: [
405
+ { value: 'interactive', label: '交互模式', desc: '仅在收到消息时响应' },
406
+ { value: 'proactive', label: '主动模式', desc: 'Agent 可主动推进任务' },
407
+ ] } },
404
408
  ]
405
409
  });
406
410
  items.push({
@@ -515,6 +519,57 @@ export class CommandHandler {
515
519
  }
516
520
  return null;
517
521
  }
522
+ /** 菜单 exec 模式:查询状态或执行命令,返回结构化数据 */
523
+ async execMenu(cmd, mode, channel, channelId, userId) {
524
+ const session = await this.sessionManager.getActiveSession(channel, channelId);
525
+ if (!session)
526
+ return { error: '当前无活跃会话' };
527
+ const trimmed = cmd.trim();
528
+ const cmdBase = trimmed.split(' ')[0];
529
+ if (!cmdBase)
530
+ return { error: '缺少命令' };
531
+ const arg = trimmed.slice(cmdBase.length).trim();
532
+ if (cmdBase === '/perm') {
533
+ const currentMode = session.metadata?.permissionMode ?? DEFAULT_PERMISSION_MODE;
534
+ if (mode === 'query') {
535
+ return { data: { mode: currentMode } };
536
+ }
537
+ // update
538
+ if (!arg)
539
+ return { error: '缺少目标模式' };
540
+ const identity = this.sessionManager.resolveIdentity(channel, userId);
541
+ if (identity.role !== 'owner')
542
+ return { error: '无权限' };
543
+ const permAgent = this.getAgent(session.agentId);
544
+ const validModes = hasPermissionController(permAgent)
545
+ ? permAgent.listModes().filter(m => m.available).map(m => m.key)
546
+ : ['auto', 'bypass', 'plan', 'edit', 'request', 'noask'];
547
+ if (!validModes.includes(arg))
548
+ return { error: `无效模式: ${arg}` };
549
+ const metadata = { ...(session.metadata || {}), permissionMode: arg };
550
+ await this.sessionManager.updateSession(session.id, { metadata });
551
+ return { data: { mode: arg } };
552
+ }
553
+ if (cmdBase === '/chatmode') {
554
+ const currentMode = session.sessionMode || 'interactive';
555
+ if (mode === 'query') {
556
+ return { data: { mode: currentMode } };
557
+ }
558
+ // update
559
+ if (!arg)
560
+ return { error: '缺少目标模式' };
561
+ if (arg !== 'interactive' && arg !== 'proactive')
562
+ return { error: `无效模式: ${arg}` };
563
+ const identity = this.sessionManager.resolveIdentity(channel, userId);
564
+ const chatType = session.chatType || 'private';
565
+ if (chatType === 'group' && identity.role !== 'owner' && identity.role !== 'admin') {
566
+ return { error: '无权限:群聊中仅管理员可切换' };
567
+ }
568
+ await this.sessionManager.updateSession(session.id, { sessionMode: arg });
569
+ return { data: { mode: arg } };
570
+ }
571
+ return { error: `不支持 exec 模式: ${cmdBase}` };
572
+ }
518
573
  isCommand(content) {
519
574
  return content === '/p' || content === '/s' || quickCommandPrefixes.some(cmd => content.startsWith(cmd));
520
575
  }
@@ -817,6 +872,31 @@ export class CommandHandler {
817
872
  const allModeKeys = hasPermissionController(permAgent) ? permAgent.listModes().map(m => m.key).join('|') : 'auto|bypass|request|edit|plan|noask';
818
873
  return `❌ 未知参数: ${args}\n用法: /perm <${allModeKeys}> 或 /perm allow|always|deny`;
819
874
  }
875
+ // /ask 命令:回答 AskUserQuestion / ExitPlanMode 的交互式问题
876
+ if (normalizedContent.startsWith('/ask')) {
877
+ const args = normalizedContent.slice(4).trim();
878
+ if (!args) {
879
+ // 无参数:列出当前 pending 的交互请求
880
+ const askResult = await this.ensureSession(channel, channelId, threadId);
881
+ if ('error' in askResult)
882
+ return askResult.error;
883
+ const pendingIds = this.interactionRouter?.getPending(askResult.session.id) || [];
884
+ if (pendingIds.length === 0)
885
+ return '当前没有待回答的问题';
886
+ return `当前有 ${pendingIds.length} 个待回答问题,请回复 /ask <选项>`;
887
+ }
888
+ const askResult = await this.ensureSession(channel, channelId, threadId);
889
+ if ('error' in askResult)
890
+ return askResult.error;
891
+ const { session: askSession } = askResult;
892
+ const pendingIds = this.interactionRouter?.getPending(askSession.id) || [];
893
+ if (pendingIds.length === 0)
894
+ return '❌ 当前没有待回答的问题';
895
+ // 路由到最早的 pending interaction
896
+ const targetId = pendingIds[0];
897
+ this.interactionRouter.handle({ type: 'interaction.response', id: targetId, action: args, operatorId: userId });
898
+ return `✓ 已回答`;
899
+ }
820
900
  // /agent 命令:查看或切换 Agent 后端
821
901
  if (normalizedContent === '/agent' || normalizedContent.startsWith('/agent ')) {
822
902
  const args = normalizedContent.slice(6).trim();
@@ -2809,6 +2889,7 @@ export class CommandHandler {
2809
2889
  * 从 session 恢复 ReplyContext,用于 ctl send 主动发送文本时的路由
2810
2890
  * - 群聊话题:metadata.replyContext.{threadId,peerId}
2811
2891
  * - 私聊:metadata.peerId
2892
+ * - taskId/chatmode:从 processing_state 和 sessionMode 注入
2812
2893
  */
2813
2894
  buildCtlReplyContext(session) {
2814
2895
  const ctx = {};
@@ -2819,6 +2900,18 @@ export class CommandHandler {
2819
2900
  ctx.peerId = meta.replyContext.peerId;
2820
2901
  if (!ctx.peerId && meta?.peerId)
2821
2902
  ctx.peerId = meta.peerId;
2903
+ const taskId = this.sessionManager.getActiveTaskId(session.id);
2904
+ const chatmode = session.sessionMode || 'interactive';
2905
+ const encrypted = this.sessionManager.getSessionEncrypt(session.id);
2906
+ if (taskId || chatmode !== 'interactive' || encrypted != null) {
2907
+ ctx.metadata = {};
2908
+ if (taskId)
2909
+ ctx.metadata.taskId = taskId;
2910
+ if (chatmode !== 'interactive')
2911
+ ctx.metadata.chatmode = chatmode;
2912
+ if (encrypted != null)
2913
+ ctx.metadata.encrypted = encrypted;
2914
+ }
2822
2915
  return Object.keys(ctx).length > 0 ? ctx : undefined;
2823
2916
  }
2824
2917
  /**
@@ -151,8 +151,19 @@ export class MessageBridge {
151
151
  if (!parsed || typeof parsed !== 'object' || !parsed.type)
152
152
  return false;
153
153
  if (parsed.type === 'menu.query') {
154
- const identity = this.sessionManager.resolveIdentity(channel, msg.peerId);
155
- if (parsed.cmd) {
154
+ if (parsed.cmd && (parsed.mode === 'query' || parsed.mode === 'update')) {
155
+ // exec 模式:查询状态或执行命令
156
+ const result = await this.cmdHandler.execMenu(parsed.cmd, parsed.mode, channel, msg.channelId, msg.peerId);
157
+ const base = { type: 'menu.response', cmd: parsed.cmd };
158
+ const response = JSON.stringify('error' in result ? { ...base, error: result.error } : { ...base, data: result.data });
159
+ if (adapter?.sendCustomPayload) {
160
+ adapter.sendCustomPayload(msg.channelId, response);
161
+ }
162
+ else {
163
+ await sendReply(msg.channelId, response);
164
+ }
165
+ }
166
+ else if (parsed.cmd) {
156
167
  // 动态子菜单查询
157
168
  const items = await this.cmdHandler.getSubMenuItems(parsed.cmd, channel, msg.channelId, msg.peerId);
158
169
  const response = JSON.stringify({ type: 'menu.response', cmd: parsed.cmd, items: items ?? [] });
@@ -165,6 +176,7 @@ export class MessageBridge {
165
176
  }
166
177
  else {
167
178
  // 全量菜单
179
+ const identity = this.sessionManager.resolveIdentity(channel, msg.peerId);
168
180
  const items = this.cmdHandler.getMenuItems(identity.role, msg.chatType || 'private');
169
181
  const response = JSON.stringify({ type: 'menu.response', items });
170
182
  if (adapter?.sendCustomPayload) {
@@ -316,7 +316,8 @@ export class MessageProcessor {
316
316
  });
317
317
  const imageInfo = message.images && message.images.length > 0 ? ` [${message.images.length} image(s)]` : '';
318
318
  const modeInfo = isBackground ? ' [\u540e\u53f0]' : '';
319
- logger.info(`[${message.channel}] ${message.channelId}: ${message.content}${imageInfo}${modeInfo}`);
319
+ const e2eeInfo = message.replyContext?.metadata?.encrypted != null ? ` encrypt=${message.replyContext.metadata.encrypted}` : '';
320
+ logger.info(`[${message.channel}] ${message.channelId}: ${message.content}${imageInfo}${modeInfo}${e2eeInfo}`);
320
321
  logger.info(`[MessageProcessor] session=${session.id} task=${taskId} chatType=${session.chatType} sessionMode=${session.sessionMode} agentId=${session.agentId} msgChatType=${message.chatType ?? 'n/a'}`);
321
322
  // 记录开始处理
322
323
  this.eventBus.publish({ type: 'message:processing', sessionId: session.id });
@@ -363,7 +364,7 @@ export class MessageProcessor {
363
364
  // Proactive 模式可观测:创建 ThoughtEmitter,将静默的流式事件转发为 thought
364
365
  // selector: context = { type: 'task', id: taskId }
365
366
  if (isProactive && adapter.putThought) {
366
- thoughtEmitter = new ThoughtEmitter(adapter, message.channelId, taskId, chatmode);
367
+ thoughtEmitter = new ThoughtEmitter(adapter, message.channelId, taskId, chatmode, this.getReplyContext(message));
367
368
  }
368
369
  // 调用 AgentRunner(含上下文过长自动 compact 重试)
369
370
  // 捕获当前消息的上下文(闭包),避免后续消息处理时串台
@@ -389,7 +390,10 @@ export class MessageProcessor {
389
390
  // 设置 per-session 权限模式(默认 bypass,所有角色统一)
390
391
  agent.setMode(session.metadata?.permissionMode ?? DEFAULT_PERMISSION_MODE);
391
392
  // 标记会话为处理中(实时持久化,重启后可恢复)
392
- this.sessionManager.markProcessing(session.id);
393
+ this.sessionManager.markProcessing(session.id, taskId);
394
+ if (message.replyContext?.metadata?.encrypted != null) {
395
+ this.sessionManager.setSessionEncrypt(session.id, !!(message.replyContext.metadata.encrypted));
396
+ }
393
397
  logger.info(`[MessageProcessor] session ${session.id} marked as processing task=${taskId}`);
394
398
  // 检查是否因新消息自动中断 — 包装 prompt 让 Agent 知道上下文
395
399
  const prevInterruptReason = this.interruptedSessions.get(session.id);
@@ -14,8 +14,9 @@ export class ThoughtEmitter {
14
14
  channelId;
15
15
  taskId;
16
16
  chatmode;
17
+ replyContext;
17
18
  hasEmittedText = false;
18
- constructor(adapter, channelId, taskId, chatmode = 'proactive') {
19
+ constructor(adapter, channelId, taskId, chatmode = 'proactive', replyContext) {
19
20
  if (!taskId) {
20
21
  throw new Error('[ThoughtEmitter] taskId is required at construction');
21
22
  }
@@ -23,6 +24,7 @@ export class ThoughtEmitter {
23
24
  this.channelId = channelId;
24
25
  this.taskId = taskId;
25
26
  this.chatmode = chatmode;
27
+ this.replyContext = replyContext;
26
28
  logger.info(`[ThoughtEmitter] created channel=${channelId} task=${taskId} chatmode=${chatmode}`);
27
29
  }
28
30
  async emit(event) {
@@ -45,7 +47,7 @@ export class ThoughtEmitter {
45
47
  payload.task_id = this.taskId;
46
48
  payload.chatmode = this.chatmode;
47
49
  try {
48
- await this.adapter.putThought(this.channelId, this.taskId, payload);
50
+ await this.adapter.putThought(this.channelId, this.taskId, payload, this.replyContext);
49
51
  }
50
52
  catch (err) {
51
53
  logger.debug(`[ThoughtEmitter] putThought failed: ${err.message}`);
@@ -13,6 +13,7 @@ export class SessionManager {
13
13
  adminResolver;
14
14
  sessionModeResolver;
15
15
  fileAdapters = new Map();
16
+ sessionEncryptState = new Map();
16
17
  constructor(dbPath = resolvePaths().db, eventBus, ownerResolver, adminResolver) {
17
18
  ensureDir(path.dirname(dbPath));
18
19
  this.db = new DatabaseSync(dbPath);
@@ -428,11 +429,21 @@ export class SessionManager {
428
429
  }
429
430
  /**
430
431
  * 标记会话为处理中(实时写 DB,crash 也能恢复)
432
+ * processing_state 格式: "timestamp:taskId"
431
433
  */
432
- markProcessing(sessionId) {
434
+ markProcessing(sessionId, taskId) {
433
435
  const now = Date.now();
436
+ const state = taskId ? `${now}:${taskId}` : String(now);
434
437
  this.db.prepare(`UPDATE sessions SET processing_state = ?, updated_at = ? WHERE id = ?`)
435
- .run(String(now), now, sessionId);
438
+ .run(state, now, sessionId);
439
+ }
440
+ /** 从 processing_state 解析当前活跃 taskId */
441
+ getActiveTaskId(sessionId) {
442
+ const row = this.db.prepare(`SELECT processing_state FROM sessions WHERE id = ?`).get(sessionId);
443
+ if (!row?.processing_state)
444
+ return undefined;
445
+ const colonIdx = row.processing_state.indexOf(':');
446
+ return colonIdx > 0 ? row.processing_state.slice(colonIdx + 1) : undefined;
436
447
  }
437
448
  /**
438
449
  * 清除会话处理中状态
@@ -440,6 +451,13 @@ export class SessionManager {
440
451
  clearProcessing(sessionId) {
441
452
  this.db.prepare(`UPDATE sessions SET processing_state = NULL, updated_at = ? WHERE id = ?`)
442
453
  .run(Date.now(), sessionId);
454
+ this.sessionEncryptState.delete(sessionId);
455
+ }
456
+ setSessionEncrypt(sessionId, encrypted) {
457
+ this.sessionEncryptState.set(sessionId, encrypted);
458
+ }
459
+ getSessionEncrypt(sessionId) {
460
+ return this.sessionEncryptState.get(sessionId);
443
461
  }
444
462
  /**
445
463
  * 获取所有处于 processing 状态的会话(用于重启后恢复)
@@ -453,7 +471,8 @@ export class SessionManager {
453
471
  const now = Date.now();
454
472
  const result = [];
455
473
  for (const row of rows) {
456
- const ts = parseInt(row.processing_state, 10);
474
+ const colonIdx = row.processing_state.indexOf(':');
475
+ const ts = parseInt(colonIdx > 0 ? row.processing_state.slice(0, colonIdx) : row.processing_state, 10);
457
476
  if (!isNaN(ts) && (now - ts) < maxAgeMs) {
458
477
  result.push(this.rowToSession(row));
459
478
  }
package/dist/index.js CHANGED
@@ -289,6 +289,14 @@ async function main() {
289
289
  });
290
290
  });
291
291
  }
292
+ // proactive 模式入站白名单:注入 sessionMode 查询器
293
+ if (typeof inst.channel.setSessionModeResolver === 'function') {
294
+ const chName = inst.adapter.channelName;
295
+ inst.channel.setSessionModeResolver(async (channelId) => {
296
+ const session = await sessionManager.getActiveSession(chName, channelId);
297
+ return session?.sessionMode;
298
+ });
299
+ }
292
300
  }
293
301
  if (channelType === 'dingtalk') {
294
302
  msgBridge.register(inst.adapter.channelName, (handler) => inst.channel.onMessage(async (event) => {
@@ -22,6 +22,7 @@
22
22
  调用 Bash 工具执行命令 :evolclaw ctl send "<消息内容>"
23
23
  发送文件: evolclaw ctl file <路径>
24
24
  可多次调用发送多条消息 ,如果不想回复停止调用即可。
25
+ 禁止使用 AskUserQuestion 和 ExitPlanMode 工具——proactive 模式下应由你主动用 ctl send 与用户沟通。
25
26
 
26
27
 
27
28
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolclaw",
3
- "version": "2.7.2",
3
+ "version": "2.8.0",
4
4
  "description": "Lightweight AI Agent gateway connecting Claude Agent SDK to messaging channels (Feishu, ACP) with multi-project session management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,7 +23,7 @@
23
23
  "prepublishOnly": "npm run build && npm test"
24
24
  },
25
25
  "dependencies": {
26
- "@agentunion/fastaun": "^0.2.15",
26
+ "@agentunion/fastaun": "^0.2.17",
27
27
  "@anthropic-ai/claude-agent-sdk": "^0.2.100",
28
28
  "image-type": "^6.0.0",
29
29
  "qrcode-terminal": "^0.12.0"