evolclaw 3.3.0 → 3.4.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 (44) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +7 -3
  3. package/dist/agents/claude-runner.js +23 -27
  4. package/dist/agents/codex-runner.js +90 -6
  5. package/dist/agents/runner-types.js +30 -0
  6. package/dist/aun/outbox.js +14 -2
  7. package/dist/channels/aun.js +506 -108
  8. package/dist/channels/feishu.js +29 -5
  9. package/dist/cli/agent-command.js +591 -0
  10. package/dist/cli/agent.js +15 -3
  11. package/dist/cli/aun-commands.js +1444 -0
  12. package/dist/cli/ctl-command.js +78 -0
  13. package/dist/cli/daemon-commands.js +2707 -0
  14. package/dist/cli/index.js +12 -5027
  15. package/dist/cli/restart-monitor.js +539 -0
  16. package/dist/cli/watch-logs.js +33 -0
  17. package/dist/core/channel-loader.js +4 -1
  18. package/dist/core/command/command-handler.js +1189 -0
  19. package/dist/core/command/menu-handler.js +1478 -0
  20. package/dist/core/command/slash-gate.js +142 -0
  21. package/dist/core/command/slash-handler.js +2090 -0
  22. package/dist/core/evolagent-registry.js +81 -0
  23. package/dist/core/evolagent.js +16 -0
  24. package/dist/core/message/im-renderer.js +67 -49
  25. package/dist/core/message/message-bridge.js +30 -9
  26. package/dist/core/message/message-processor.js +200 -122
  27. package/dist/core/message/message-queue.js +68 -0
  28. package/dist/core/permission.js +16 -0
  29. package/dist/core/session/session-manager.js +59 -13
  30. package/dist/core/stats/db.js +20 -0
  31. package/dist/core/stats/writer.js +3 -3
  32. package/dist/data/error-dict.json +7 -0
  33. package/dist/index.js +49 -6
  34. package/dist/ipc.js +99 -0
  35. package/dist/utils/cross-platform.js +35 -0
  36. package/dist/utils/ecweb-launch.js +49 -0
  37. package/dist/utils/error-utils.js +18 -5
  38. package/dist/utils/npm-ops.js +38 -8
  39. package/dist/utils/stats.js +63 -6
  40. package/kits/eck_manifest.json +0 -12
  41. package/package.json +2 -3
  42. package/dist/core/command-handler.js +0 -4235
  43. package/dist/core/message/response-depth.js +0 -56
  44. package/kits/templates/system-fragments/response-depth.md +0 -16
@@ -206,6 +206,20 @@ export class AUNChannel {
206
206
  const name = cached?.name;
207
207
  return name && name !== short ? `${short}(${name})` : short;
208
208
  }
209
+ notifyCardActionFailure(channelId, text, operatorId, threadId) {
210
+ if (!channelId) {
211
+ logger.warn(`${this.logPrefix()} Card action failure without channelId: ${text}`);
212
+ return;
213
+ }
214
+ const context = {};
215
+ if (threadId)
216
+ context.threadId = threadId;
217
+ if (operatorId && this.isGroupId(channelId))
218
+ context.peerId = operatorId;
219
+ void this.sendMessage(channelId, text, context).catch((err) => {
220
+ logger.error(`${this.logPrefix()} Failed to send card action error:`, err);
221
+ });
222
+ }
209
223
  extractTextPayload(payload, channelId, senderAid) {
210
224
  if (typeof payload === 'string')
211
225
  return payload;
@@ -220,6 +234,7 @@ export class AUNChannel {
220
234
  if (cardInfo) {
221
235
  const actionValue = typeof obj.value === 'string' ? obj.value
222
236
  : typeof obj.action_value === 'string' ? obj.action_value : text;
237
+ const threadId = typeof obj.thread_id === 'string' ? obj.thread_id : undefined;
223
238
  // 卡片点击者身份:只信认证信封(senderAid 参数,由调用方从 msg.from / msg.sender_aid 提取)。
224
239
  // payload 自报字段(from / sender_aid / user_id)不可信,可被客户端伪造,不读取。
225
240
  // 两类卡片共用:CommandCard → 伪入站消息的 peerId,ActionInteraction → operatorId。
@@ -234,9 +249,10 @@ export class AUNChannel {
234
249
  if (chatType === 'group' && cardInfo.initiatorAid && cardClickerAid
235
250
  && cardClickerAid !== cardInfo.initiatorAid) {
236
251
  logger.info(`${this.logPrefix()} CommandCard rejected: clicker=${cardClickerAid} initiator=${cardInfo.initiatorAid} mid=${cardMsgId}`);
252
+ this.notifyCardActionFailure(channelId, '⚠️ 仅卡片发起者可操作', cardClickerAid, threadId);
237
253
  return '';
238
254
  }
239
- this.messageHandler({
255
+ Promise.resolve(this.messageHandler({
240
256
  channelId: channelId || '',
241
257
  chatType,
242
258
  content: actionValue,
@@ -244,8 +260,18 @@ export class AUNChannel {
244
260
  peerName: typeof obj.label === 'string' ? obj.label : typeof obj.action_label === 'string' ? obj.action_label : undefined,
245
261
  messageId: `card-trigger-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
246
262
  source: 'card-trigger',
263
+ })).catch((err) => {
264
+ logger.error(`${this.logPrefix()} CommandCard handler failed: action=${actionValue} mid=${cardMsgId}`, err);
265
+ const message = err instanceof Error && err.message ? err.message : String(err || '未知错误');
266
+ this.notifyCardActionFailure(channelId, `❌ 操作失败: ${message}`, cardClickerAid, threadId);
247
267
  });
248
268
  }
269
+ else if (!this.messageHandler) {
270
+ this.notifyCardActionFailure(channelId, '❌ 操作失败:命令处理器未就绪', cardClickerAid, threadId);
271
+ }
272
+ else {
273
+ this.notifyCardActionFailure(channelId, '❌ 操作失败:无效的卡片命令', cardClickerAid, threadId);
274
+ }
249
275
  }
250
276
  else {
251
277
  // ActionInteraction:走 interactionCallback → InteractionRouter
@@ -260,10 +286,19 @@ export class AUNChannel {
260
286
  operatorId: cardClickerAid || undefined,
261
287
  });
262
288
  }
289
+ else {
290
+ this.notifyCardActionFailure(channelId, '❌ 操作失败:交互处理器未就绪', cardClickerAid, threadId);
291
+ }
263
292
  }
264
293
  }
294
+ else if (this.ownedCardMsgIds.has(cardMsgId)) {
295
+ // 本 agent 发出的卡片,但 entry 已过期(20min TTL)
296
+ logger.debug(`${this.logPrefix()} action_card_reply expired: cardMsgId=${cardMsgId}`);
297
+ this.notifyCardActionFailure(channelId, '⚠️ 卡片已失效,请重新发起');
298
+ }
265
299
  else {
266
- logger.debug(`${this.logPrefix()} action_card_reply dropped: cardMsgId=${cardMsgId} hasCallback=${!!this.interactionCallback}`);
300
+ // 非本 agent 发出的卡片(broadcast 模式下其他 agent 的卡)→ 静默忽略
301
+ logger.debug(`${this.logPrefix()} action_card_reply ignored (not owned): cardMsgId=${cardMsgId}`);
267
302
  }
268
303
  // 始终返回空字符串,阻止消息分发给 agent
269
304
  return '';
@@ -404,6 +439,13 @@ export class AUNChannel {
404
439
  .replace(/[ \t]+/g, ' ')
405
440
  .trim();
406
441
  }
442
+ /** 剥离正文中所有 @aid(用于命令判定 + 命令消息进 agent 前的清理)。 */
443
+ stripAllMentions(text) {
444
+ return text
445
+ .replace(/(^|\s)@[\w.-]+(?=$|\s|[.,!?;:,。!?;:]|[\u4e00-\u9fff])/g, '$1')
446
+ .replace(/[ \t]+/g, ' ')
447
+ .trim();
448
+ }
407
449
  extractMentionAids(mentions) {
408
450
  const aids = [];
409
451
  for (const m of mentions) {
@@ -470,6 +512,8 @@ export class AUNChannel {
470
512
  interactionCallback;
471
513
  // action_card message_id → { requestId, isCommandCard }(用于关联 action_card_reply)
472
514
  cardMessageIdMap = new Map();
515
+ /** 本 agent 曾发出过的卡片 msgId(只增不删,用于区分"过期失效"vs"他人发的卡") */
516
+ ownedCardMsgIds = new Set();
473
517
  dispatchModeResolver;
474
518
  static PROACTIVE_ALLOW_TYPES = new Set([
475
519
  'text', 'quote', 'image', 'video', 'voice', 'file', 'json',
@@ -508,6 +552,7 @@ export class AUNChannel {
508
552
  // AID 连接状态(供 status 命令聚合展示)
509
553
  aidState;
510
554
  aidStatsCollector;
555
+ outboxInFlight = new Set();
511
556
  constructor(config) {
512
557
  this.config = config;
513
558
  this.agentDir = agentDirPath(config.aid);
@@ -1108,10 +1153,13 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1108
1153
  logger.info(`${this.logPrefix()} P2P dispatched: from=${shortAid}(${displayName}) mid=${messageId} encrypt=${msgEncrypted} chatmode=${msgChatmode ?? 'none'} text=${finalText.slice(0, 60)}`);
1109
1154
  appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_in', aid: this.config.aid, from: fromAid, msgId: messageId, kind: 'text', len: finalText.length });
1110
1155
  const isSystemP2P = p2pPayloadType === 'event';
1111
- this.aidStatsCollector?.recordInbound(this.config.aid, fromAid, Buffer.byteLength(finalText, 'utf-8'), finalText, isSystemP2P, msgEncrypted, msgChatmode);
1156
+ this.aidStatsCollector?.recordInbound(this.config.aid, fromAid, Buffer.byteLength(finalText, 'utf-8'), finalText, isSystemP2P, msgEncrypted, msgChatmode, isSystemP2P ? 'notify' : 'send');
1112
1157
  const replyContext = { metadata: { encrypted: msgEncrypted, chatmode: msgChatmode } };
1113
1158
  if (threadId)
1114
1159
  replyContext.threadId = threadId;
1160
+ replyContext.peerId = fromAid;
1161
+ if (messageId)
1162
+ replyContext.replyToMessageId = messageId;
1115
1163
  this.dispatchMessage({
1116
1164
  channelId: chatId,
1117
1165
  userId: fromAid,
@@ -1237,7 +1285,11 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1237
1285
  // 包含 [EvolClaw.xxx] trace 说明已被本系统处理过,是回声的回声,丢弃防止链式爆炸
1238
1286
  const firstLineGroup = text.split('\n')[0] || '';
1239
1287
  const hasEvolClawTraceGroup = /\[EvolClaw\.(receive|reply|agent)\]/.test(text);
1240
- if (/echo/i.test(firstLineGroup) && !hasEvolClawTraceGroup) {
1288
+ const isEchoMsg = /echo/i.test(firstLineGroup) && !hasEvolClawTraceGroup;
1289
+ // 命令判定:剥离所有 @ 后看是否 / 开头(多 @ 场景如 @a @b /status 也能正确识别)。
1290
+ // echo 消息走独立的 trace 流程,不参与命令语义判定。
1291
+ const isCommandMsg = !isEchoMsg && this.stripAllMentions(text).startsWith('/');
1292
+ if (isEchoMsg) {
1241
1293
  // 短 echo(≤10 字符)已在前面的快速通道命中并 return,这里只处理长 echo
1242
1294
  // >10 字符:追加 trace,存 pending echo,跳过 mention 过滤继续走 Agent 流程
1243
1295
  const echoTs = () => {
@@ -1262,14 +1314,21 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1262
1314
  return;
1263
1315
  }
1264
1316
  else {
1265
- // 非 echo 消息:正常 mention 过滤
1266
- if (dispatchMode === 'mention' && !mentionedSelf && !mentionedAll) {
1317
+ // 非 echo 消息:mention 过滤
1318
+ // 命令豁免 broadcast:slash 命令在任何 dispatchMode 下都强制走 mention 语义,
1319
+ // 即必须 @ 本 agent(或 @all)才处理,避免广播群里一条命令被全部 agent 各自执行。
1320
+ const enforceMention = dispatchMode === 'mention' || isCommandMsg;
1321
+ if (enforceMention && !mentionedSelf && !mentionedAll) {
1267
1322
  this.acknowledgeImmediately(messageId, seq);
1268
- logger.info(`${this.logPrefix()} Group dropped: unmentioned in mention-mode (group=${groupId} sender=${senderAid} mid=${messageId} textPreview=${JSON.stringify(text.slice(0, 80))})`);
1323
+ logger.info(`${this.logPrefix()} Group dropped: unmentioned (group=${groupId} sender=${senderAid} mid=${messageId} mode=${dispatchMode} isCommand=${isCommandMsg} textPreview=${JSON.stringify(text.slice(0, 80))})`);
1269
1324
  return;
1270
1325
  }
1271
1326
  }
1272
- const strippedText = this.stripSelfMentionIfOnly(text, this._aid);
1327
+ // 命令消息:剥离所有 @(多 agent 被 @ 时各自拿到干净的 /status 各自执行);
1328
+ // 普通消息:仅在唯一 @ 是自己时剥离,保留其他 @ 供 agent 感知。
1329
+ const strippedText = isCommandMsg
1330
+ ? this.stripAllMentions(text)
1331
+ : this.stripSelfMentionIfOnly(text, this._aid);
1273
1332
  // Detect attachments before the empty-text guard (顶层 + 嵌套)
1274
1333
  const rawAttachments = this.collectAllAttachments(payload);
1275
1334
  const hasAttachments = rawAttachments.length > 0;
@@ -1306,7 +1365,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1306
1365
  const msgChatmode = (payload && typeof payload === 'object') ? payload.chatmode : undefined;
1307
1366
  logger.info(`${this.logPrefix()} Group dispatched: group=${groupId} sender=${shortAid}(${displayName}) mode=${dispatchMode} mid=${messageId} chatmode=${msgChatmode ?? 'none'} text=${finalText.slice(0, 60)}`);
1308
1367
  appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_in', aid: this.config.aid, from: senderAid, msgId: messageId, kind: 'text', len: finalText.length, groupId });
1309
- this.aidStatsCollector?.recordInbound(this.config.aid, senderAid, Buffer.byteLength(finalText, 'utf-8'), finalText, payloadType === 'event', msgEncrypted, msgChatmode);
1368
+ this.aidStatsCollector?.recordInbound(this.config.aid, senderAid, Buffer.byteLength(finalText, 'utf-8'), finalText, payloadType === 'event', msgEncrypted, msgChatmode, payloadType === 'event' ? 'notify' : 'send');
1310
1369
  // 渲染用完整 @ 列表:结构化 payload.mentions + 正文 @aid 兜底,去重(含 self / "all")。
1311
1370
  // 与上面用于过滤/回复的精简 mentions 独立——这份不丢任何被 @ 的 AID。
1312
1371
  const renderMentionAids = Array.from(new Set([
@@ -1330,7 +1389,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1330
1389
  mentions,
1331
1390
  mentionAids: renderMentionAids.length > 0 ? renderMentionAids : undefined,
1332
1391
  replyContext: this.buildGroupReplyContext(threadId, senderAid, msgEncrypted, messageId, msgChatmode),
1333
- dispatchMode,
1392
+ dispatchMode: serverDispatchMode,
1334
1393
  images: inboundImages.length > 0 ? inboundImages : undefined,
1335
1394
  });
1336
1395
  }
@@ -1574,11 +1633,11 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1574
1633
  const len = Buffer.byteLength(text, 'utf-8');
1575
1634
  if (dir === 'in') {
1576
1635
  appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_in', aid: this.config.aid, from: ownerAid, msgId, kind: 'text', len, inject: true });
1577
- this.aidStatsCollector?.recordInbound(this.config.aid, ownerAid, len, text, false, false, 'inject');
1636
+ this.aidStatsCollector?.recordInbound(this.config.aid, ownerAid, len, text, false, false, 'inject', 'inject');
1578
1637
  }
1579
1638
  else {
1580
1639
  appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: ownerAid, msgId, kind: 'text', len, inject: true });
1581
- this.aidStatsCollector?.recordOutbound(this.config.aid, ownerAid, len, text, false, false, 'inject');
1640
+ this.aidStatsCollector?.recordOutbound(this.config.aid, ownerAid, len, text, false, false, 'inject', 'inject');
1582
1641
  }
1583
1642
  }
1584
1643
  catch (e) {
@@ -1935,6 +1994,219 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1935
1994
  onRecall(handler) {
1936
1995
  this.recallHandler = handler;
1937
1996
  }
1997
+ async withOutboxInFlight(entry, run, busyValue) {
1998
+ if (this.outboxInFlight.has(entry.id))
1999
+ return busyValue;
2000
+ this.outboxInFlight.add(entry.id);
2001
+ try {
2002
+ return await run();
2003
+ }
2004
+ finally {
2005
+ this.outboxInFlight.delete(entry.id);
2006
+ }
2007
+ }
2008
+ messageIdFromSendResult(result) {
2009
+ return result?.message?.message_id ?? result?.message_id ?? null;
2010
+ }
2011
+ payloadLogText(payload, contentKind) {
2012
+ if (typeof payload.text === 'string' && payload.text)
2013
+ return payload.text;
2014
+ switch (contentKind ?? payload.type) {
2015
+ case 'image':
2016
+ return payload.alt ? `[image] ${payload.alt}` : '[image]';
2017
+ case 'card':
2018
+ case 'action_card':
2019
+ return payload.title ? `[card] ${payload.title}` : '[card]';
2020
+ case 'file':
2021
+ return payload.filename ? `[file] ${payload.filename}` : '[file]';
2022
+ default:
2023
+ return `[${String(payload.type ?? contentKind ?? 'payload')}]`;
2024
+ }
2025
+ }
2026
+ payloadByteLength(payload, logText) {
2027
+ if (payload.type === 'image' && typeof payload.data_base64 === 'string') {
2028
+ return Buffer.byteLength(payload.data_base64, 'utf-8');
2029
+ }
2030
+ return Buffer.byteLength(logText, 'utf-8');
2031
+ }
2032
+ applyReplyContextToPayload(payload, context) {
2033
+ const finalPayload = { ...payload };
2034
+ if (context?.threadId && !finalPayload.thread_id)
2035
+ finalPayload.thread_id = context.threadId;
2036
+ if (context?.metadata?.taskId && !finalPayload.task_id)
2037
+ finalPayload.task_id = context.metadata.taskId;
2038
+ if (context?.metadata?.chatmode && !finalPayload.chatmode)
2039
+ finalPayload.chatmode = context.metadata.chatmode;
2040
+ return finalPayload;
2041
+ }
2042
+ registerCardPostSend(messageId, action) {
2043
+ if (action.type !== 'register_interaction_card')
2044
+ return;
2045
+ this.cardMessageIdMap.set(messageId, {
2046
+ requestId: action.requestId,
2047
+ isCommandCard: action.isCommandCard,
2048
+ initiatorAid: action.initiatorAid,
2049
+ });
2050
+ this.ownedCardMsgIds.add(messageId);
2051
+ const now = Date.now();
2052
+ const mapTtl = action.expiresAt && action.expiresAt > now
2053
+ ? action.expiresAt - now
2054
+ : 20 * 60 * 1000;
2055
+ setTimeout(() => this.cardMessageIdMap.delete(messageId), mapTtl);
2056
+ setTimeout(() => this.ownedCardMsgIds.delete(messageId), 24 * 60 * 60 * 1000);
2057
+ }
2058
+ runPostSend(entry, messageId) {
2059
+ if (!entry.postSend)
2060
+ return;
2061
+ if (entry.postSend.type === 'register_interaction_card') {
2062
+ this.registerCardPostSend(messageId, entry.postSend);
2063
+ }
2064
+ }
2065
+ async sendAunPayload(channelId, payload, context, label) {
2066
+ if (!this.client || !this.connected)
2067
+ return { ok: false };
2068
+ const isGroup = this.isGroupId(channelId);
2069
+ const targetAid = channelId;
2070
+ const encryptTarget = isGroup ? channelId : targetAid;
2071
+ const encrypt = context?.metadata?.encrypted != null
2072
+ ? !!(context.metadata.encrypted)
2073
+ : this.shouldEncrypt(encryptTarget);
2074
+ const method = isGroup ? 'group.send' : 'message.send';
2075
+ const params = { payload, encrypt };
2076
+ if (isGroup)
2077
+ params.group_id = channelId;
2078
+ else
2079
+ params.to = targetAid;
2080
+ const callOnce = async (sendParams, fallback) => {
2081
+ const result = fallback
2082
+ ? await this.client.call(method, sendParams)
2083
+ : await this.callAndTrace(method, sendParams);
2084
+ const mid = this.messageIdFromSendResult(result);
2085
+ if (!mid) {
2086
+ logger.warn(`${this.logPrefix()} ${method}${fallback ? ' fallback' : ''} (${label}) returned no message_id: ${JSON.stringify(result)}`);
2087
+ return { ok: false, result, encrypt: !!sendParams.encrypt };
2088
+ }
2089
+ return { ok: true, messageId: mid, result, encrypt: !!sendParams.encrypt };
2090
+ };
2091
+ try {
2092
+ return await callOnce(params, false);
2093
+ }
2094
+ catch (e) {
2095
+ if (encrypt && e instanceof E2EEError) {
2096
+ this.peerE2ee.set(encryptTarget, { ok: false, ts: Date.now() });
2097
+ logger.warn(`${this.logPrefix()} E2EE ${label} send failed to ${channelId}, retrying plaintext: ${e}`);
2098
+ const fallbackParams = { ...params, encrypt: false };
2099
+ try {
2100
+ this.trace('OUT', `${method}.${label}.fallback`, fallbackParams);
2101
+ const sent = await callOnce(fallbackParams, true);
2102
+ this.trace('OUT', `${method}.${label}.fallback.${sent.ok ? 'ok' : 'missing_id'}`, { message_id: sent.messageId });
2103
+ return sent;
2104
+ }
2105
+ catch (e2) {
2106
+ this.trace('OUT', `${method}.${label}.fallback.error`, { channelId, error: String(e2) });
2107
+ logger.error(`${this.logPrefix()} Plaintext ${label} fallback also failed to ${channelId}: ${e2}`);
2108
+ return { ok: false };
2109
+ }
2110
+ }
2111
+ this.trace('OUT', `${method}.${label}.error`, { channelId, error: String(e) });
2112
+ logger.error(`${this.logPrefix()} ${label} send failed to ${channelId}: ${e}`);
2113
+ return { ok: false };
2114
+ }
2115
+ }
2116
+ recordDurableOutbound(channelId, payload, messageId, encrypt, context, isGroup, contentKind, logText, result) {
2117
+ const kind = contentKind ?? payload.type ?? 'custom';
2118
+ appendAidEvent({
2119
+ ts: Date.now(),
2120
+ iso: new Date().toISOString(),
2121
+ event: 'message_out',
2122
+ aid: this.config.aid,
2123
+ to: channelId,
2124
+ msgId: messageId,
2125
+ kind,
2126
+ len: Buffer.byteLength(logText, 'utf-8'),
2127
+ ...(isGroup && { groupId: channelId }),
2128
+ });
2129
+ this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, this.payloadByteLength(payload, logText), logText, false, encrypt, context?.metadata?.chatmode);
2130
+ const source = context?.metadata?.source ?? 'daemon';
2131
+ this.appendOutboundJsonl(channelId, logText, messageId, encrypt, context, isGroup, 'text', source);
2132
+ this.forwardOutbound(result);
2133
+ }
2134
+ async sendContentPayload(channelId, payload, opts) {
2135
+ const finalPayload = this.applyReplyContextToPayload(payload, opts.context);
2136
+ const logText = opts.logText ?? this.payloadLogText(finalPayload, opts.contentKind);
2137
+ const entry = outbox.enqueue(this.config.aid, {
2138
+ channelId,
2139
+ type: 'payload',
2140
+ contentKind: opts.contentKind,
2141
+ payload: finalPayload,
2142
+ context: opts.context,
2143
+ logText,
2144
+ ttl: opts.ttl,
2145
+ postSend: opts.postSend,
2146
+ });
2147
+ logger.debug(`${this.logPrefix()} Outbox enqueued payload: id=${entry.id} kind=${opts.contentKind} channel=${channelId} text=${logText.slice(0, 40)}`);
2148
+ if (!this.connected || !this.client) {
2149
+ logger.warn(`${this.logPrefix()} Not connected, payload queued in outbox (id=${entry.id}, kind=${opts.contentKind}). Triggering reconnect.`);
2150
+ if (!this.reconnectTimer && !this.client) {
2151
+ this.initClient().catch(e => logger.error(`${this.logPrefix()} Reconnect from sendContentPayload failed: ${e}`));
2152
+ }
2153
+ return { queued: true };
2154
+ }
2155
+ const result = await this.withOutboxInFlight(entry, () => this.deliverPayloadEntry(entry), { ok: false });
2156
+ if (result.ok) {
2157
+ outbox.remove(this.config.aid, entry.id);
2158
+ return { messageId: result.messageId };
2159
+ }
2160
+ return { queued: true };
2161
+ }
2162
+ buildTaskPayloadBase(envelope, context) {
2163
+ const base = {};
2164
+ if (envelope.taskId)
2165
+ base.task_id = envelope.taskId;
2166
+ if (envelope.sessionId)
2167
+ base.session_id = envelope.sessionId;
2168
+ if (envelope.agentName)
2169
+ base.agent_name = envelope.agentName;
2170
+ if (envelope.chatmode)
2171
+ base.chatmode = envelope.chatmode;
2172
+ if (context?.threadId)
2173
+ base.thread_id = context.threadId;
2174
+ if (context?.peerId)
2175
+ base.initiator = context.peerId;
2176
+ if (context?.replyToMessageId)
2177
+ base.ref_message_id = context.replyToMessageId;
2178
+ return base;
2179
+ }
2180
+ activityLogText(raw) {
2181
+ // 兼容两种入参:原始 ThoughtItem(顶层字段),或已构建的 activity payload(字段收在 .item 里)
2182
+ const item = raw?.item && typeof raw.item === 'object' ? raw.item : raw;
2183
+ if (typeof item?.text === 'string' && item.text)
2184
+ return item.text;
2185
+ if (item?.kind === 'tool_call')
2186
+ return `[tool_call] ${item.name ?? ''}`.trim();
2187
+ if (item?.kind === 'tool_result')
2188
+ return `[tool_result] ${item.name ?? ''} ${item.ok === false ? 'failed' : 'ok'}`.trim();
2189
+ if (item?.kind)
2190
+ return `[activity:${item.kind}]`;
2191
+ return '[activity]';
2192
+ }
2193
+ buildActivityPayload(envelope, context, item) {
2194
+ const activityItem = item && typeof item === 'object' && !Array.isArray(item)
2195
+ ? { ...item }
2196
+ : { kind: 'unknown', text: String(item ?? '') };
2197
+ return {
2198
+ ...this.buildTaskPayloadBase(envelope, context),
2199
+ type: 'activity',
2200
+ item: activityItem,
2201
+ };
2202
+ }
2203
+ async sendReliableStructured(channelId, payload, context, logText) {
2204
+ await this.sendContentPayload(channelId, payload, {
2205
+ contentKind: 'custom',
2206
+ context,
2207
+ logText: logText ?? this.payloadLogText(payload, 'custom'),
2208
+ });
2209
+ }
1938
2210
  async sendMessage(channelId, text, context) {
1939
2211
  if (!text?.trim()) {
1940
2212
  logger.warn(`${this.logPrefix()} Attempted to send empty message, skipping`);
@@ -1967,7 +2239,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1967
2239
  return;
1968
2240
  }
1969
2241
  // Attempt immediate delivery
1970
- const ok = await this.deliverTextEntry(entry);
2242
+ const ok = await this.withOutboxInFlight(entry, () => this.deliverTextEntry(entry), false);
1971
2243
  if (ok) {
1972
2244
  outbox.remove(this.config.aid, entry.id);
1973
2245
  }
@@ -2049,16 +2321,17 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2049
2321
  if (!mid) {
2050
2322
  const dispatchStatus = result?.message_dispatch?.status;
2051
2323
  if (dispatchStatus === 'debounced' || dispatchStatus === 'dispatched') {
2052
- logger.info(`${this.logPrefix()} group.send ok (${dispatchStatus}): group=${channelId} encrypt=${encrypt} text=${finalText.slice(0, 60)}`);
2324
+ logger.warn(`${this.logPrefix()} group.send returned ${dispatchStatus} without message_id; keeping outbox entry: group=${channelId} encrypt=${encrypt} text=${finalText.slice(0, 60)}`);
2053
2325
  }
2054
2326
  else {
2055
2327
  logger.warn(`${this.logPrefix()} group.send returned no message_id: ${JSON.stringify(result)}`);
2056
2328
  }
2329
+ return false;
2057
2330
  }
2058
2331
  else {
2059
2332
  logger.info(`${this.logPrefix()} group.send ok: group=${channelId} mid=${mid} encrypt=${encrypt} text=${finalText.slice(0, 60)}`);
2060
2333
  appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: channelId, msgId: mid, kind: 'text', len: finalText.length, groupId: channelId });
2061
- this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, Buffer.byteLength(finalText, 'utf-8'), finalText, false, encrypt, context?.metadata?.chatmode);
2334
+ this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, Buffer.byteLength(finalText, 'utf-8'), finalText, false, encrypt, context?.metadata?.chatmode, 'send');
2062
2335
  this.appendOutboundJsonl(channelId, finalText, mid, encrypt, context, true, 'text', source);
2063
2336
  // Observer forward: outbound (group) — 原样转发 SDK SendResult(含 envelope + payload)
2064
2337
  this.forwardOutbound(result);
@@ -2069,11 +2342,12 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2069
2342
  const result = await this.callAndTrace('message.send', params);
2070
2343
  if (!result || !result.message_id) {
2071
2344
  logger.warn(`${this.logPrefix()} message.send returned no message_id: ${JSON.stringify(result)}`);
2345
+ return false;
2072
2346
  }
2073
2347
  else {
2074
2348
  logger.info(`${this.logPrefix()} message.send ok: to=${this.peerLabel(targetAid)} mid=${result.message_id} encrypt=${encrypt} text=${finalText.slice(0, 60)}`);
2075
2349
  appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: targetAid, msgId: result.message_id, kind: 'text', len: finalText.length });
2076
- this.aidStatsCollector?.recordOutbound(this.config.aid, targetAid, Buffer.byteLength(finalText, 'utf-8'), finalText, false, encrypt, context?.metadata?.chatmode);
2350
+ this.aidStatsCollector?.recordOutbound(this.config.aid, targetAid, Buffer.byteLength(finalText, 'utf-8'), finalText, false, encrypt, context?.metadata?.chatmode, 'send');
2077
2351
  this.appendOutboundJsonl(targetAid, finalText, result.message_id, encrypt, context, false, 'text', source);
2078
2352
  // Observer forward: outbound (private) — 原样转发 SDK SendResult(含 envelope + payload)
2079
2353
  this.forwardOutbound(result);
@@ -2090,19 +2364,29 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2090
2364
  if (isGroup) {
2091
2365
  this.trace('OUT', 'group.send.fallback', params);
2092
2366
  const result = await this.client.call('group.send', params);
2093
- this.trace('OUT', 'group.send.fallback.ok', { message_id: result?.message?.message_id ?? result?.message_id });
2094
- if (!result || !result.message_id) {
2367
+ const mid = this.messageIdFromSendResult(result);
2368
+ this.trace('OUT', 'group.send.fallback.ok', { message_id: mid });
2369
+ if (!mid) {
2095
2370
  logger.warn(`${this.logPrefix()} group.send fallback returned no message_id: ${JSON.stringify(result)}`);
2371
+ return false;
2096
2372
  }
2373
+ appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: channelId, msgId: mid, kind: 'text', len: finalText.length, groupId: channelId });
2374
+ this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, Buffer.byteLength(finalText, 'utf-8'), finalText, false, false, context?.metadata?.chatmode);
2375
+ this.appendOutboundJsonl(channelId, finalText, mid, false, context, true, 'text', source);
2097
2376
  this.forwardOutbound(result);
2098
2377
  }
2099
2378
  else {
2100
2379
  this.trace('OUT', 'message.send.fallback', params);
2101
2380
  const result = await this.client.call('message.send', params);
2102
- this.trace('OUT', 'message.send.fallback.ok', { message_id: result?.message_id });
2103
- if (!result || !result.message_id) {
2381
+ const mid = result?.message_id;
2382
+ this.trace('OUT', 'message.send.fallback.ok', { message_id: mid });
2383
+ if (!result || !mid) {
2104
2384
  logger.warn(`${this.logPrefix()} message.send fallback returned no message_id: ${JSON.stringify(result)}`);
2385
+ return false;
2105
2386
  }
2387
+ appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: targetAid, msgId: mid, kind: 'text', len: finalText.length });
2388
+ this.aidStatsCollector?.recordOutbound(this.config.aid, targetAid, Buffer.byteLength(finalText, 'utf-8'), finalText, false, false, context?.metadata?.chatmode);
2389
+ this.appendOutboundJsonl(targetAid, finalText, mid, false, context, false, 'text', source);
2106
2390
  this.forwardOutbound(result);
2107
2391
  }
2108
2392
  return true;
@@ -2120,6 +2404,26 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2120
2404
  }
2121
2405
  }
2122
2406
  }
2407
+ async deliverPayloadEntry(entry) {
2408
+ const channelId = entry.channelId;
2409
+ const payload = entry.payload;
2410
+ if (!payload) {
2411
+ logger.warn(`${this.logPrefix()} deliverPayloadEntry: missing payload (outbox id=${entry.id})`);
2412
+ return { ok: true };
2413
+ }
2414
+ const contentKind = entry.contentKind;
2415
+ const logText = entry.logText ?? this.payloadLogText(payload, contentKind);
2416
+ const context = entry.context;
2417
+ logger.info(`${this.logPrefix()} deliverPayloadEntry: id=${entry.id} kind=${contentKind ?? payload.type ?? 'payload'} channelId=${channelId} thread_id=${payload.thread_id ?? 'none'} task_id=${payload.task_id ?? 'none'} textLen=${logText.length}`);
2418
+ const sent = await this.sendAunPayload(channelId, payload, context, `${contentKind ?? payload.type ?? 'payload'}`);
2419
+ if (!sent.ok || !sent.messageId)
2420
+ return sent;
2421
+ const isGroup = this.isGroupId(channelId);
2422
+ logger.info(`${this.logPrefix()} durable payload sent: kind=${contentKind ?? payload.type ?? 'payload'} target=${isGroup ? channelId : this.peerLabel(channelId)} mid=${sent.messageId} encrypt=${sent.encrypt} text=${logText.slice(0, 60)}`);
2423
+ this.recordDurableOutbound(channelId, payload, sent.messageId, !!sent.encrypt, context, isGroup, contentKind, logText, sent.result);
2424
+ this.runPostSend(entry, sent.messageId);
2425
+ return sent;
2426
+ }
2123
2427
  /** 出站消息写入 messages.jsonl(message.send/group.send/thought.put 成功后调用) */
2124
2428
  appendOutboundJsonl(channelId, text, msgId, encrypt, context, isGroup, msgType = 'text', source = 'daemon') {
2125
2429
  try {
@@ -2172,10 +2476,10 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2172
2476
  encrypt,
2173
2477
  };
2174
2478
  try {
2175
- const itemCount = Array.isArray(payload?.items) ? payload.items.length : 0;
2176
- const stage = payload?.stage ?? `items=${itemCount}`;
2177
- // 提取 thought 文本(只对 kind=text 的 item 写 jsonl,过滤 tool_use/tool_result 等结构化项)
2178
2479
  const items = payload?.items;
2480
+ const itemCount = Array.isArray(items) ? items.length : 1;
2481
+ const stage = payload?.stage ?? (payload?.kind ? `kind=${payload.kind}` : `items=${itemCount}`);
2482
+ // 提取 thought 文本:兼容旧 items[] 和新扁平 activity payload。
2179
2483
  let thoughtText;
2180
2484
  if (Array.isArray(items) && items.length > 0) {
2181
2485
  const lastItem = items[items.length - 1];
@@ -2193,6 +2497,9 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2193
2497
  thoughtText = lastItem;
2194
2498
  }
2195
2499
  }
2500
+ else {
2501
+ thoughtText = this.activityLogText(payload);
2502
+ }
2196
2503
  if (this.isGroupId(channelId)) {
2197
2504
  params.group_id = targetId;
2198
2505
  const putRes = await this.callAndTrace('group.thought.put', params);
@@ -2201,7 +2508,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2201
2508
  this.eventBus?.publish?.({ type: 'message:thought-put', agentName: this.config.aid, channelId, taskId, text: thoughtText });
2202
2509
  this.forwardOutbound(putRes);
2203
2510
  if (thoughtText) {
2204
- this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, Buffer.byteLength(thoughtText, 'utf-8'), thoughtText, false, encrypt, context?.metadata?.chatmode ?? 'proactive');
2511
+ this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, Buffer.byteLength(thoughtText, 'utf-8'), thoughtText, false, encrypt, context?.metadata?.chatmode ?? 'proactive', 'thought');
2205
2512
  this.appendOutboundJsonl(channelId, thoughtText, tid ?? `thought-${Date.now()}`, encrypt, context, true, 'thought', 'daemon');
2206
2513
  }
2207
2514
  }
@@ -2213,7 +2520,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2213
2520
  this.eventBus?.publish?.({ type: 'message:thought-put', agentName: this.config.aid, channelId, taskId, text: thoughtText });
2214
2521
  this.forwardOutbound(putRes);
2215
2522
  if (thoughtText) {
2216
- this.aidStatsCollector?.recordOutbound(this.config.aid, targetId, Buffer.byteLength(thoughtText, 'utf-8'), thoughtText, false, encrypt, context?.metadata?.chatmode ?? 'proactive');
2523
+ this.aidStatsCollector?.recordOutbound(this.config.aid, targetId, Buffer.byteLength(thoughtText, 'utf-8'), thoughtText, false, encrypt, context?.metadata?.chatmode ?? 'proactive', 'thought');
2217
2524
  this.appendOutboundJsonl(channelId, thoughtText, tid ?? `thought-${Date.now()}`, encrypt, context, false, 'thought', 'daemon');
2218
2525
  }
2219
2526
  }
@@ -2295,7 +2602,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2295
2602
  }
2296
2603
  return;
2297
2604
  }
2298
- const ok = await this.deliverFileEntry(entry);
2605
+ const ok = await this.withOutboxInFlight(entry, () => this.deliverFileEntry(entry), false);
2299
2606
  if (ok) {
2300
2607
  outbox.remove(this.config.aid, entry.id);
2301
2608
  }
@@ -2365,6 +2672,9 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2365
2672
  filePayload.task_id = context.metadata.taskId;
2366
2673
  if (context?.metadata?.chatmode)
2367
2674
  filePayload.chatmode = context.metadata.chatmode;
2675
+ // file-link-cache: 回带点击请求的 correlationId,客户端用它把异步到达的文件消息对回这次 fetch 点击
2676
+ if (context?.metadata?.correlationId)
2677
+ filePayload.correlation_id = context.metadata.correlationId;
2368
2678
  const isGroup = this.isGroupId(channelId);
2369
2679
  const fileTargetAid = channelId;
2370
2680
  const encryptTarget = isGroup ? channelId : fileTargetAid;
@@ -2373,6 +2683,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2373
2683
  : this.shouldEncrypt(encryptTarget);
2374
2684
  const params = { payload: filePayload, encrypt };
2375
2685
  let sendResult = null;
2686
+ let sentMid = null;
2376
2687
  try {
2377
2688
  if (isGroup) {
2378
2689
  params.group_id = channelId;
@@ -2380,9 +2691,11 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2380
2691
  const result = await this.client.call('group.send', params);
2381
2692
  sendResult = result;
2382
2693
  const fileMid = result?.message?.message_id ?? result?.message_id;
2694
+ sentMid = fileMid ?? null;
2383
2695
  this.trace('OUT', 'group.send.file.ok', { message_id: fileMid });
2384
2696
  if (!fileMid) {
2385
2697
  logger.warn(`${this.logPrefix()} group.send.file returned no message_id: ${JSON.stringify(result)}`);
2698
+ return false;
2386
2699
  }
2387
2700
  }
2388
2701
  else {
@@ -2390,9 +2703,11 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2390
2703
  this.trace('OUT', 'message.send.file', params);
2391
2704
  const result = await this.client.call('message.send', params);
2392
2705
  sendResult = result;
2393
- this.trace('OUT', 'message.send.file.ok', { message_id: result?.message_id });
2394
- if (!result || !result.message_id) {
2706
+ sentMid = result?.message_id ?? null;
2707
+ this.trace('OUT', 'message.send.file.ok', { message_id: sentMid });
2708
+ if (!result || !sentMid) {
2395
2709
  logger.warn(`${this.logPrefix()} message.send.file returned no message_id: ${JSON.stringify(result)}`);
2710
+ return false;
2396
2711
  }
2397
2712
  }
2398
2713
  }
@@ -2410,18 +2725,22 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2410
2725
  const result = await this.client.call('group.send', params);
2411
2726
  sendResult = result;
2412
2727
  const fbMid = result?.message?.message_id ?? result?.message_id;
2728
+ sentMid = fbMid ?? null;
2413
2729
  this.trace('OUT', 'group.send.file.fallback.ok', { message_id: fbMid });
2414
2730
  if (!fbMid) {
2415
2731
  logger.warn(`${this.logPrefix()} group.send.file fallback returned no message_id: ${JSON.stringify(result)}`);
2732
+ return false;
2416
2733
  }
2417
2734
  }
2418
2735
  else {
2419
2736
  this.trace('OUT', 'message.send.file.fallback', params);
2420
2737
  const result = await this.client.call('message.send', params);
2421
2738
  sendResult = result;
2422
- this.trace('OUT', 'message.send.file.fallback.ok', { message_id: result?.message_id });
2423
- if (!result || !result.message_id) {
2739
+ sentMid = result?.message_id ?? null;
2740
+ this.trace('OUT', 'message.send.file.fallback.ok', { message_id: sentMid });
2741
+ if (!result || !sentMid) {
2424
2742
  logger.warn(`${this.logPrefix()} message.send.file fallback returned no message_id: ${JSON.stringify(result)}`);
2743
+ return false;
2425
2744
  }
2426
2745
  }
2427
2746
  }
@@ -2430,6 +2749,13 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2430
2749
  }
2431
2750
  }
2432
2751
  logger.info(`${this.logPrefix()} File sent: ${filename} (${formatSize(stat.size)}) → ${channelId}`);
2752
+ if (sentMid) {
2753
+ const fileText = filePayload.text;
2754
+ appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: channelId, msgId: sentMid, kind: 'file', len: fileText.length, ...(isGroup && { groupId: channelId }) });
2755
+ this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, Buffer.byteLength(fileText, 'utf-8'), fileText, false, !!params.encrypt, context?.metadata?.chatmode);
2756
+ const source = context?.metadata?.source ?? 'daemon';
2757
+ this.appendOutboundJsonl(channelId, fileText, sentMid, !!params.encrypt, context, isGroup, 'text', source);
2758
+ }
2433
2759
  if (sendResult)
2434
2760
  this.forwardOutbound(sendResult);
2435
2761
  return true;
@@ -2465,10 +2791,14 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2465
2791
  logger.info(`${this.logPrefix()} Draining outbox...`);
2466
2792
  const result = await outbox.drain(this.config.aid, async (entry) => {
2467
2793
  if (entry.type === 'text') {
2468
- return this.deliverTextEntry(entry);
2794
+ return this.withOutboxInFlight(entry, () => this.deliverTextEntry(entry), false);
2469
2795
  }
2470
2796
  else if (entry.type === 'file') {
2471
- return this.deliverFileEntry(entry);
2797
+ return this.withOutboxInFlight(entry, () => this.deliverFileEntry(entry), false);
2798
+ }
2799
+ else if (entry.type === 'payload') {
2800
+ const sent = await this.withOutboxInFlight(entry, () => this.deliverPayloadEntry(entry), { ok: false });
2801
+ return sent.ok;
2472
2802
  }
2473
2803
  return true; // unknown type, discard
2474
2804
  });
@@ -2497,62 +2827,44 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2497
2827
  progress: 'progress',
2498
2828
  };
2499
2829
  const statusPayload = {
2500
- type: 'status',
2501
- state: stateMap[status] ?? status,
2830
+ type: 'task.status',
2831
+ status: stateMap[status] ?? status,
2502
2832
  task_id: taskId,
2503
2833
  session_id: sessionId,
2504
2834
  severity,
2835
+ terminal: ['done', 'interrupted', 'error', 'timeout'].includes(status),
2505
2836
  ...(extraMeta && Object.keys(extraMeta).length > 0 && { metadata: extraMeta }),
2506
2837
  };
2507
2838
  if (context?.threadId)
2508
2839
  statusPayload.thread_id = context.threadId;
2509
2840
  if (context?.peerId)
2510
2841
  statusPayload.initiator = context.peerId;
2842
+ if (context?.metadata?.chatmode)
2843
+ statusPayload.chatmode = context.metadata.chatmode;
2511
2844
  if (context?.replyToMessageId)
2512
2845
  statusPayload.ref_message_id = context.replyToMessageId;
2513
- const isGroup = this.isGroupId(channelId);
2514
- // 私聊 channelId = 对端 AID(不含 device_id)
2515
- const statusTargetAid = channelId;
2516
- const encryptTarget = isGroup ? channelId : statusTargetAid;
2517
- const computeEncrypt = () => context?.metadata?.encrypted != null
2518
- ? !!(context.metadata.encrypted)
2519
- : this.shouldEncrypt(encryptTarget);
2520
- const sendOne = (method, payload, label) => {
2521
- const c = this.client;
2522
- if (!c) {
2523
- logger.debug(`${this.logPrefix()} ${label} skipped: client gone`);
2524
- return Promise.resolve(null);
2525
- }
2526
- const encrypt = computeEncrypt();
2527
- const params = { payload, encrypt };
2528
- if (isGroup)
2529
- params.group_id = channelId;
2530
- else
2531
- params.to = statusTargetAid;
2532
- this.trace('OUT', `${method}.task_${label}`, params);
2533
- return c.call(method, params).catch((e) => {
2534
- if (encrypt && e instanceof E2EEError) {
2535
- this.peerE2ee.set(encryptTarget, { ok: false, ts: Date.now() });
2536
- logger.warn(`${this.logPrefix()} E2EE task_${label} send failed to ${channelId}, retrying plaintext`);
2537
- const c2 = this.client;
2538
- if (!c2)
2539
- return null;
2540
- const fallbackParams = { ...params, encrypt: false };
2541
- return c2.call(method, fallbackParams).catch((e2) => {
2542
- logger.debug(`${this.logPrefix()} task_${label} fallback failed: ${e2}`);
2543
- return null;
2544
- });
2545
- }
2546
- logger.debug(`${this.logPrefix()} task_${label} failed: ${e}`);
2547
- return null;
2548
- });
2549
- };
2550
- const method = isGroup ? 'group.send' : 'message.send';
2551
- sendOne(method, statusPayload, 'status').then(result => {
2552
- if (result)
2553
- this.forwardOutbound(result);
2846
+ const notifyOptions = { ttlMs: 60_000 };
2847
+ if (this.isGroupId(channelId))
2848
+ notifyOptions.groupId = channelId;
2849
+ else
2850
+ notifyOptions.to = channelId;
2851
+ this.trace('OUT', 'notify.task_status', {
2852
+ method: 'event/app.task.status',
2853
+ params: statusPayload,
2854
+ options: notifyOptions,
2855
+ });
2856
+ const notify = this.client.notify;
2857
+ if (typeof notify !== 'function') {
2858
+ logger.warn(`${this.logPrefix()} task.${status} notify skipped: client.notify unavailable`);
2859
+ return;
2860
+ }
2861
+ Promise.resolve(notify.call(this.client, 'event/app.task.status', statusPayload, notifyOptions)).then(() => {
2862
+ this.trace('OUT', 'notify.task_status.ok', { task_id: taskId, status: statusPayload.status });
2863
+ }).catch((e) => {
2864
+ this.trace('OUT', 'notify.task_status.error', { task_id: taskId, status: statusPayload.status, error: String(e) });
2865
+ logger.debug(`${this.logPrefix()} task_status notify failed: ${e}`);
2554
2866
  }).catch(() => { });
2555
- this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, JSON.stringify(statusPayload).length, undefined, true);
2867
+ this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, JSON.stringify(statusPayload).length, undefined, true, undefined, undefined, 'notify');
2556
2868
  // 群聊显示 group id 简称,P2P 显示 peer label;从 context.metadata 读取 chatmode
2557
2869
  const targetLabel = this.isGroupId(channelId) ? channelId : this.peerLabel(channelId);
2558
2870
  const chatmode = context?.metadata?.chatmode ?? '?';
@@ -2775,6 +3087,31 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
2775
3087
  return undefined; // 不写缓存,下次仍可重试
2776
3088
  }
2777
3089
  }
3090
+ /**
3091
+ * 查询某成员在群里的角色(经 group.get_admins)。
3092
+ * 仅用于话题创建权限校验(稀有事件),故不缓存:每次查权威源,结果天然最新。
3093
+ * 命中 admins 列表(owner+admin)返回其 role;不在列表(含普通 member / observer)返回 'none';
3094
+ * 未连接 / 异常返回 undefined —— 绝不抛出,由调用方按 fail-closed 处理。
3095
+ */
3096
+ async getGroupMemberRole(groupId, aid) {
3097
+ if (!groupId || !aid)
3098
+ return undefined;
3099
+ if (!this.client)
3100
+ return undefined;
3101
+ try {
3102
+ const result = await this.callAndTrace('group.get_admins', { group_id: groupId });
3103
+ const admins = Array.isArray(result?.admins) ? result.admins : [];
3104
+ const hit = admins.find((m) => m?.aid === aid);
3105
+ if (hit) {
3106
+ const role = hit.role;
3107
+ return role === 'owner' || role === 'admin' ? role : 'admin';
3108
+ }
3109
+ return 'none'; // 不在 owner/admin 列表 → 普通 member / observer / 非成员
3110
+ }
3111
+ catch {
3112
+ return undefined; // 查询失败,调用方 fail-closed
3113
+ }
3114
+ }
2778
3115
  }
2779
3116
  // Plugin implementation
2780
3117
  export class AUNChannelPlugin {
@@ -2804,43 +3141,89 @@ export class AUNChannelPlugin {
2804
3141
  send: async (envelope, payload) => {
2805
3142
  const replyCtx = envelope.replyContext;
2806
3143
  const channelId = envelope.channelId;
3144
+ const taskBase = () => channel.buildTaskPayloadBase(envelope, replyCtx);
2807
3145
  switch (payload.kind) {
2808
3146
  case 'result.text':
2809
3147
  case 'command.result':
2810
- case 'command.error':
2811
- case 'system.notice':
2812
- case 'system.error':
2813
- case 'result.error': {
3148
+ case 'command.error': {
2814
3149
  const sendCtx = { ...(replyCtx ?? {}) };
2815
3150
  if (payload.kind === 'result.text' && payload.isFinal)
2816
3151
  sendCtx.title = '✅ 最终回复:';
2817
3152
  await channel.sendMessage(channelId, payload.text, sendCtx);
2818
3153
  return;
2819
3154
  }
2820
- case 'result.file':
2821
- await channel.sendFile(channelId, payload.filePath, replyCtx);
3155
+ case 'system.notice': {
3156
+ await channel.sendReliableStructured(channelId, {
3157
+ type: 'notice',
3158
+ ...taskBase(),
3159
+ subtype: payload.subtype,
3160
+ text: payload.text,
3161
+ severity: 'info',
3162
+ }, replyCtx, payload.text);
3163
+ return;
3164
+ }
3165
+ case 'system.error': {
3166
+ await channel.sendReliableStructured(channelId, {
3167
+ type: 'error',
3168
+ ...taskBase(),
3169
+ subtype: payload.subtype,
3170
+ message: payload.text,
3171
+ user_message: payload.text,
3172
+ recoverable: payload.recoverable,
3173
+ terminal: !payload.recoverable,
3174
+ }, replyCtx, payload.text);
2822
3175
  return;
3176
+ }
3177
+ case 'result.error': {
3178
+ await channel.sendReliableStructured(channelId, {
3179
+ type: 'error',
3180
+ ...taskBase(),
3181
+ reason: payload.reason,
3182
+ message: payload.text,
3183
+ user_message: payload.text,
3184
+ terminal: true,
3185
+ }, replyCtx, payload.text);
3186
+ return;
3187
+ }
3188
+ case 'result.file': {
3189
+ const fileCtx = payload.correlationId
3190
+ ? { ...(replyCtx ?? {}), metadata: { ...(replyCtx?.metadata ?? {}), correlationId: payload.correlationId } }
3191
+ : replyCtx;
3192
+ await channel.sendFile(channelId, payload.filePath, fileCtx);
3193
+ return;
3194
+ }
2823
3195
  case 'result.image': {
2824
3196
  const buf = payload.data;
2825
3197
  const b64 = buf.toString('base64');
2826
- await channel.sendStructured(channelId, {
3198
+ await channel.sendContentPayload(channelId, {
2827
3199
  type: 'image', alt: payload.alt, data_base64: b64, mime_type: payload.mimeType,
2828
- }, replyCtx);
3200
+ }, {
3201
+ contentKind: 'image',
3202
+ context: replyCtx,
3203
+ logText: payload.alt ? `[image] ${payload.alt}` : '[image]',
3204
+ });
2829
3205
  return;
2830
3206
  }
2831
3207
  case 'activity.batch': {
2832
- const aunPayload = {
2833
- type: 'thought',
2834
- items: payload.items,
2835
- client_context: { task_id: envelope.taskId, chatmode: envelope.chatmode, agent_name: envelope.agentName },
2836
- };
2837
- if (replyCtx?.threadId)
2838
- aunPayload.thread_id = replyCtx.threadId;
2839
- if (envelope.chatmode === 'proactive') {
2840
- await channel.sendThought(channelId, envelope.taskId, aunPayload, replyCtx);
2841
- }
2842
- else {
2843
- await channel.sendStructured(channelId, aunPayload, replyCtx);
3208
+ const items = Array.isArray(payload.items) ? payload.items : [];
3209
+ for (const item of items) {
3210
+ if (item?.kind === 'progress') {
3211
+ channel.sendProcessingStatus(channelId, 'progress', envelope.sessionId ?? envelope.taskId, envelope.taskId, replyCtx, {
3212
+ activityType: 'progress',
3213
+ text: item.text,
3214
+ state: item.state,
3215
+ toolUses: item.tool_uses,
3216
+ durationMs: item.duration_ms,
3217
+ });
3218
+ continue;
3219
+ }
3220
+ const aunPayload = channel.buildActivityPayload(envelope, replyCtx, item);
3221
+ if (envelope.chatmode === 'proactive') {
3222
+ await channel.sendThought(channelId, envelope.taskId, aunPayload, replyCtx);
3223
+ }
3224
+ else {
3225
+ await channel.sendReliableStructured(channelId, aunPayload, replyCtx, channel.activityLogText(item));
3226
+ }
2844
3227
  }
2845
3228
  return;
2846
3229
  }
@@ -2882,11 +3265,18 @@ export class AUNChannelPlugin {
2882
3265
  aunCard.initiator = req.initiatorId;
2883
3266
  if (replyCtx?.threadId)
2884
3267
  aunCard.thread_id = replyCtx.threadId;
2885
- const msgId = await channel.sendStructured(channelId, aunCard, replyCtx);
2886
- if (msgId) {
2887
- channel.cardMessageIdMap.set(msgId, { requestId: req.id, isCommandCard: false, initiatorAid: req.initiatorId });
2888
- setTimeout(() => channel.cardMessageIdMap.delete(msgId), 20 * 60 * 1000);
2889
- }
3268
+ await channel.sendContentPayload(channelId, aunCard, {
3269
+ contentKind: 'card',
3270
+ context: replyCtx,
3271
+ logText: action.title ? `[card] ${action.title}` : '[card]',
3272
+ postSend: {
3273
+ type: 'register_interaction_card',
3274
+ requestId: req.id,
3275
+ isCommandCard: false,
3276
+ initiatorAid: req.initiatorId,
3277
+ expiresAt: Date.now() + 20 * 60 * 1000,
3278
+ },
3279
+ });
2890
3280
  }
2891
3281
  else if (req.kind.kind === 'command-card') {
2892
3282
  const card = req.kind;
@@ -2894,18 +3284,25 @@ export class AUNChannelPlugin {
2894
3284
  type: 'action_card',
2895
3285
  title: card.title,
2896
3286
  actions: card.buttons.map(btn => ({
2897
- label: btn.label, value: btn.command, style: btn.style ?? 'default', behavior: 'reply',
3287
+ label: btn.label, value: btn.command, style: btn.style ?? 'default', behavior: 'reply', disabled: btn.disabled || undefined,
2898
3288
  })),
2899
3289
  };
2900
3290
  if (card.body)
2901
3291
  aunCard.description = card.body;
2902
3292
  if (replyCtx?.threadId)
2903
3293
  aunCard.thread_id = replyCtx.threadId;
2904
- const msgId = await channel.sendStructured(channelId, aunCard, replyCtx);
2905
- if (msgId) {
2906
- channel.cardMessageIdMap.set(msgId, { requestId: req.id, isCommandCard: true, initiatorAid: req.initiatorId });
2907
- setTimeout(() => channel.cardMessageIdMap.delete(msgId), 20 * 60 * 1000);
2908
- }
3294
+ await channel.sendContentPayload(channelId, aunCard, {
3295
+ contentKind: 'card',
3296
+ context: replyCtx,
3297
+ logText: card.title ? `[card] ${card.title}` : '[card]',
3298
+ postSend: {
3299
+ type: 'register_interaction_card',
3300
+ requestId: req.id,
3301
+ isCommandCard: true,
3302
+ initiatorAid: req.initiatorId,
3303
+ expiresAt: Date.now() + 20 * 60 * 1000,
3304
+ },
3305
+ });
2909
3306
  }
2910
3307
  else if (payload.fallbackText) {
2911
3308
  await channel.sendMessage(channelId, payload.fallbackText, replyCtx);
@@ -2926,6 +3323,7 @@ export class AUNChannelPlugin {
2926
3323
  uploadAgentMd: (content) => channel.uploadAgentMd(content),
2927
3324
  downloadAgentMd: (aid) => channel.downloadAgentMd(aid),
2928
3325
  getGroupName: (groupId) => channel.getGroupName(groupId),
3326
+ getGroupMemberRole: (groupId, aid) => channel.getGroupMemberRole(groupId, aid),
2929
3327
  _selfAid: () => channel.getStatus().aid,
2930
3328
  _selfName: () => channel.getSelfName(),
2931
3329
  };
@@ -2969,7 +3367,7 @@ export class AUNChannelPlugin {
2969
3367
  if (typeof channel.setDispatchModeResolver === 'function') {
2970
3368
  channel.setDispatchModeResolver(async (channelId) => {
2971
3369
  const session = await hookCtx.sessionManager.getActiveSession(adapter.channelName, channelId);
2972
- return session?.metadata?.dispatchMode;
3370
+ return session?.metadata?.dispatchModeOverride;
2973
3371
  });
2974
3372
  }
2975
3373
  },