evolclaw 2.8.3 → 3.0.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 (105) hide show
  1. package/README.md +21 -12
  2. package/dist/agents/claude-runner.js +102 -38
  3. package/dist/agents/codex-runner.js +11 -14
  4. package/dist/agents/gemini-runner.js +10 -12
  5. package/dist/agents/resolve.js +134 -0
  6. package/dist/agents/templates.js +3 -3
  7. package/dist/aun/aid/agentmd.js +186 -0
  8. package/dist/aun/aid/client.js +134 -0
  9. package/dist/aun/aid/identity.js +131 -0
  10. package/dist/aun/aid/index.js +3 -0
  11. package/dist/aun/aid/types.js +1 -0
  12. package/dist/aun/aid/validation.js +21 -0
  13. package/dist/aun/msg/group.js +291 -0
  14. package/dist/aun/msg/index.js +4 -0
  15. package/dist/aun/msg/p2p.js +144 -0
  16. package/dist/aun/msg/payload-type.js +27 -0
  17. package/dist/aun/msg/upload.js +98 -0
  18. package/dist/aun/outbox.js +138 -0
  19. package/dist/aun/rpc/caller.js +42 -0
  20. package/dist/aun/rpc/connection.js +34 -0
  21. package/dist/aun/rpc/index.js +2 -0
  22. package/dist/aun/storage/download.js +29 -0
  23. package/dist/aun/storage/index.js +3 -0
  24. package/dist/aun/storage/manage.js +10 -0
  25. package/dist/aun/storage/upload.js +35 -0
  26. package/dist/channels/aun.js +1051 -288
  27. package/dist/channels/dingtalk.js +58 -5
  28. package/dist/channels/feishu.js +266 -30
  29. package/dist/channels/qqbot.js +67 -12
  30. package/dist/channels/wechat.js +61 -4
  31. package/dist/channels/wecom.js +58 -5
  32. package/dist/cli/agent.js +800 -0
  33. package/dist/cli/index.js +4253 -0
  34. package/dist/{utils → cli}/init-channel.js +211 -621
  35. package/dist/cli/init.js +178 -0
  36. package/dist/config-store.js +613 -0
  37. package/dist/core/{agent-loader.js → baseagent-loader.js} +6 -12
  38. package/dist/core/channel-loader.js +162 -11
  39. package/dist/core/command-handler.js +858 -847
  40. package/dist/core/evolagent-registry.js +191 -371
  41. package/dist/core/evolagent.js +203 -234
  42. package/dist/core/interaction-router.js +52 -5
  43. package/dist/core/message/im-renderer.js +480 -0
  44. package/dist/core/message/items-formatter.js +61 -0
  45. package/dist/core/message/message-bridge.js +104 -56
  46. package/dist/core/message/message-log.js +91 -0
  47. package/dist/core/message/message-processor.js +309 -142
  48. package/dist/core/message/message-queue.js +3 -3
  49. package/dist/core/permission.js +21 -8
  50. package/dist/core/session/adapters/codex-session-file-adapter.js +24 -2
  51. package/dist/core/session/session-fs-store.js +230 -0
  52. package/dist/core/session/session-manager.js +704 -775
  53. package/dist/core/session/session-mapper.js +87 -0
  54. package/dist/core/trigger/manager.js +122 -0
  55. package/dist/core/trigger/parser.js +128 -0
  56. package/dist/core/trigger/scheduler.js +224 -0
  57. package/dist/{templates → data}/prompts.md +34 -1
  58. package/dist/index.js +431 -275
  59. package/dist/ipc.js +49 -0
  60. package/dist/paths.js +82 -9
  61. package/dist/types.js +8 -2
  62. package/dist/utils/atomic-write.js +79 -0
  63. package/dist/utils/channel-helpers.js +46 -0
  64. package/dist/utils/cross-platform.js +0 -18
  65. package/dist/utils/instance-registry.js +433 -0
  66. package/dist/utils/log-writer.js +216 -0
  67. package/dist/utils/logger.js +24 -77
  68. package/dist/utils/media-cache.js +23 -0
  69. package/dist/utils/{upgrade.js → npm-ops.js} +52 -21
  70. package/dist/utils/process-introspect.js +144 -0
  71. package/dist/utils/stats.js +192 -0
  72. package/dist/watch-msg.js +529 -0
  73. package/evolclaw-install-aun.md +114 -46
  74. package/kits/aun/meta.md +25 -0
  75. package/kits/aun/role.md +25 -0
  76. package/kits/channels/aun.md +25 -0
  77. package/kits/evolclaw/commands.md +31 -0
  78. package/kits/evolclaw/identity-tools.md +26 -0
  79. package/kits/evolclaw/self-summary.md +29 -0
  80. package/kits/evolclaw/tools.md +25 -0
  81. package/kits/templates/group.md +20 -0
  82. package/kits/templates/private.md +9 -0
  83. package/kits/templates/system-fragments/personal-context.md +3 -0
  84. package/kits/templates/system-fragments/self-intro.md +5 -0
  85. package/kits/templates/system-fragments/speaker-intro.md +5 -0
  86. package/kits/templates/system-fragments/venue-intro.md +5 -0
  87. package/package.json +7 -5
  88. package/data/evolclaw.sample.json +0 -60
  89. package/dist/channels/aun-ops.js +0 -275
  90. package/dist/cli.js +0 -2178
  91. package/dist/config.js +0 -591
  92. package/dist/core/agent-registry.js +0 -450
  93. package/dist/core/evolagent-schema.js +0 -72
  94. package/dist/core/message/stream-flusher.js +0 -238
  95. package/dist/core/message/thought-emitter.js +0 -162
  96. package/dist/core/reload-hooks.js +0 -87
  97. package/dist/prompts/templates.js +0 -122
  98. package/dist/templates/skills.md +0 -66
  99. package/dist/utils/channel-fingerprint.js +0 -59
  100. package/dist/utils/error-dict.js +0 -63
  101. package/dist/utils/format.js +0 -32
  102. package/dist/utils/init.js +0 -645
  103. package/dist/utils/migrate-project.js +0 -122
  104. package/dist/utils/reload-hooks.js +0 -87
  105. package/dist/utils/stats-collector.js +0 -99
@@ -4,28 +4,54 @@ import fs from 'fs';
4
4
  import path from 'path';
5
5
  import os from 'os';
6
6
  import { logger, localTimestamp } from '../utils/logger.js';
7
- import { normalizeChannelInstances, getChannelShowActivities } from '../config.js';
8
- import { resolvePaths } from '../paths.js';
7
+ import { LogWriter } from '../utils/log-writer.js';
8
+ import { normalizeChannelInstances, getChannelShowActivities } from '../utils/channel-helpers.js';
9
+ import { resolvePaths, getPackageRoot } from '../paths.js';
9
10
  import { saveToUploads, sanitizeFileName } from '../utils/media-cache.js';
10
- function guessMime(filename) {
11
- const ext = path.extname(filename).toLowerCase();
12
- const map = {
13
- '.txt': 'text/plain', '.md': 'text/markdown', '.json': 'application/json',
14
- '.js': 'text/javascript', '.ts': 'text/typescript', '.py': 'text/x-python',
15
- '.html': 'text/html', '.css': 'text/css', '.csv': 'text/csv',
16
- '.pdf': 'application/pdf', '.zip': 'application/zip', '.gz': 'application/gzip',
17
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
18
- '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
19
- '.xml': 'application/xml', '.yaml': 'application/x-yaml', '.yml': 'application/x-yaml',
11
+ import { appendAidEvent } from '../utils/instance-registry.js';
12
+ import { loadAgent, saveAgent } from '../config-store.js';
13
+ import { getProcessStartTime } from '../utils/process-introspect.js';
14
+ import * as outbox from '../aun/outbox.js';
15
+ import { guessMime, formatSize } from '../utils/media-cache.js';
16
+ /**
17
+ * 构造 connect extra_info:自描述本进程身份。
18
+ *
19
+ * 用途:另一个进程踢掉本连接时,本进程会从 SDK 'gateway.disconnect' 事件的
20
+ * detail.new_extra_info 里看到对方的 extra_info;同时 detail.self_extra_info
21
+ * 是本进程当时连接时上报的内容。把双方信息打到日志便于诊断"谁踢了谁"。
22
+ *
23
+ * 字段需保持稳定(被踢方靠它分辨对方身份)。
24
+ */
25
+ function buildConnectExtraInfo(opts) {
26
+ const startedAt = getProcessStartTime(process.pid) ?? Date.now();
27
+ return {
28
+ app: 'evolclaw',
29
+ version: getEvolclawVersion(),
30
+ pid: process.pid,
31
+ started_at: startedAt,
32
+ started_at_iso: new Date(startedAt).toISOString(),
33
+ hostname: os.hostname(),
34
+ platform: process.platform,
35
+ node_version: process.version,
36
+ evolclaw_home: process.env.EVOLCLAW_HOME || '',
37
+ launched_by: process.env.EVOLCLAW_LAUNCHED_BY || '',
38
+ aid: opts.aid,
39
+ agent_name: opts.agentName ?? '',
40
+ channel_name: opts.channelName ?? '',
20
41
  };
21
- return map[ext] || 'application/octet-stream';
22
42
  }
23
- function formatSize(bytes) {
24
- if (bytes < 1024)
25
- return `${bytes} B`;
26
- if (bytes < 1048576)
27
- return `${(bytes / 1024).toFixed(1)} KB`;
28
- return `${(bytes / 1048576).toFixed(1)} MB`;
43
+ let _cachedVersion = null;
44
+ function getEvolclawVersion() {
45
+ if (_cachedVersion !== null)
46
+ return _cachedVersion;
47
+ try {
48
+ const pkg = JSON.parse(fs.readFileSync(path.join(getPackageRoot(), 'package.json'), 'utf-8'));
49
+ _cachedVersion = String(pkg.version ?? '');
50
+ }
51
+ catch {
52
+ _cachedVersion = '';
53
+ }
54
+ return _cachedVersion;
29
55
  }
30
56
  export class AUNChannel {
31
57
  config;
@@ -34,13 +60,15 @@ export class AUNChannel {
34
60
  messageHandler;
35
61
  recallHandler;
36
62
  connected = false;
37
- traceStream = null;
38
- traceHourTag = ''; // 当前 trace 文件对应的小时标识 (YYYYMMDD-HH)
63
+ traceWriter = null;
64
+ eventBus = null;
65
+ ownerBoundHandler = null;
66
+ pendingEchoMessages = new Map();
67
+ isEchoSending = false;
39
68
  trace(dir, event, data) {
40
69
  if (!this.config.aunTrace)
41
70
  return;
42
- this.rotateTraceIfNeeded();
43
- if (!this.traceStream)
71
+ if (!this.traceWriter)
44
72
  return;
45
73
  // 自动从 data 推断顶层字段(self_aid / peer_aid / group_id / task_id / chatmode),
46
74
  // 便于 jq 过滤:`jq 'select(.task_id == "task-xxx")'`
@@ -64,7 +92,7 @@ export class AUNChannel {
64
92
  if (chatmode)
65
93
  topContext.chatmode = chatmode;
66
94
  const line = JSON.stringify({ ts: localTimestamp(), dir, event, ...topContext, data });
67
- this.traceStream.write(line + '\n');
95
+ this.traceWriter.write(line);
68
96
  }
69
97
  /** 日志前缀(含 self aid 简称,多实例可识别) */
70
98
  logPrefix() {
@@ -109,40 +137,6 @@ export class AUNChannel {
109
137
  throw e;
110
138
  }
111
139
  }
112
- static AUN_TRACE_RE = /^aun-\d{8}-\d{2}\.log$/;
113
- static AUN_RETAIN_HOURS = 12;
114
- rotateTraceIfNeeded() {
115
- const d = new Date();
116
- const pad = (n) => String(n).padStart(2, '0');
117
- const hourTag = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}`;
118
- if (this.traceHourTag === hourTag && this.traceStream)
119
- return;
120
- if (this.traceStream) {
121
- this.traceStream.end();
122
- this.traceStream = null;
123
- }
124
- this.traceHourTag = hourTag;
125
- const logPath = path.join(resolvePaths().logs, `aun-${hourTag}.log`);
126
- this.traceStream = fs.createWriteStream(logPath, { flags: 'a' });
127
- this.cleanupOldTraceLogs();
128
- }
129
- cleanupOldTraceLogs() {
130
- const logsDir = resolvePaths().logs;
131
- const cutoff = Date.now() - AUNChannel.AUN_RETAIN_HOURS * 60 * 60 * 1000;
132
- try {
133
- for (const name of fs.readdirSync(logsDir)) {
134
- if (!AUNChannel.AUN_TRACE_RE.test(name))
135
- continue;
136
- try {
137
- const full = path.join(logsDir, name);
138
- if (fs.statSync(full).mtimeMs < cutoff)
139
- fs.unlinkSync(full);
140
- }
141
- catch { }
142
- }
143
- }
144
- catch { }
145
- }
146
140
  /** 判断 channelId 是否为群组 ID
147
141
  * - 新格式:group.{issuer}/{group_no|group_name}
148
142
  * - 数字群号:{group_no}.{issuer}(如 11117.agentid.pub)
@@ -174,12 +168,66 @@ export class AUNChannel {
174
168
  const name = cached?.name;
175
169
  return name && name !== short ? `${short}(${name})` : short;
176
170
  }
177
- extractTextPayload(payload) {
171
+ extractTextPayload(payload, channelId) {
178
172
  if (typeof payload === 'string')
179
173
  return payload;
180
174
  if (payload && typeof payload === 'object') {
181
- const text = payload.text;
182
- if (typeof text === 'string')
175
+ const obj = payload;
176
+ const text = typeof obj.text === 'string' ? obj.text : '';
177
+ // action_card_reply:卡片交互回复,触发 interactionCallback,不分发给 agent
178
+ if (obj.type === 'action_card_reply') {
179
+ const cardMsgId = typeof obj.card_message_id === 'string' ? obj.card_message_id : '';
180
+ const cardInfo = cardMsgId ? this.cardMessageIdMap.get(cardMsgId) : undefined;
181
+ if (cardInfo) {
182
+ const actionValue = typeof obj.action_value === 'string' ? obj.action_value : text;
183
+ if (cardInfo.isCommandCard) {
184
+ // CommandCard:action_value 是完整 slash 命令,构造伪入站消息
185
+ this.cardMessageIdMap.delete(cardMsgId);
186
+ if (this.messageHandler && actionValue.startsWith('/')) {
187
+ const chatType = channelId ? (this.isGroupId(channelId) ? 'group' : 'private') : 'private';
188
+ this.messageHandler({
189
+ channelId: channelId || '',
190
+ chatType,
191
+ content: actionValue,
192
+ peerId: channelId || '',
193
+ peerName: typeof obj.action_label === 'string' ? obj.action_label : undefined,
194
+ messageId: `card-trigger-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
195
+ source: 'card-trigger',
196
+ });
197
+ }
198
+ }
199
+ else {
200
+ // ActionInteraction:走 interactionCallback → InteractionRouter
201
+ // callback 未注册时保留 map entry(TTL 清理),给 router 留重试机会
202
+ if (this.interactionCallback) {
203
+ this.cardMessageIdMap.delete(cardMsgId);
204
+ this.interactionCallback({
205
+ type: 'interaction.response',
206
+ id: cardInfo.requestId,
207
+ action: actionValue,
208
+ values: { text, action_label: obj.action_label, behavior: obj.behavior },
209
+ });
210
+ }
211
+ }
212
+ }
213
+ else {
214
+ logger.debug(`${this.logPrefix()} action_card_reply dropped: cardMsgId=${cardMsgId} hasCallback=${!!this.interactionCallback}`);
215
+ }
216
+ // 始终返回空字符串,阻止消息分发给 agent
217
+ return '';
218
+ }
219
+ // quote 类型:拼接被引用内容
220
+ if (obj.type === 'quote' && obj.quote && typeof obj.quote === 'object') {
221
+ const q = obj.quote;
222
+ const quotedText = typeof q.text === 'string' ? q.text : '';
223
+ if (quotedText) {
224
+ const sender = typeof q.sender_display === 'string' ? q.sender_display : '';
225
+ const prefix = sender ? `${sender}: ` : '';
226
+ const quoted = quotedText.split('\n').map(line => `> ${prefix}${line}`).join('\n');
227
+ return text ? `${quoted}\n\n${text}` : quoted;
228
+ }
229
+ }
230
+ if (typeof obj.text === 'string')
183
231
  return text;
184
232
  return JSON.stringify(payload);
185
233
  }
@@ -271,35 +319,96 @@ export class AUNChannel {
271
319
  static E2EE_PROBE_TTL = 10 * 60 * 1000; // 10min
272
320
  plaintextRecv = 0;
273
321
  sessionModeResolver;
322
+ interactionCallback;
323
+ // action_card message_id → { requestId, isCommandCard }(用于关联 action_card_reply)
324
+ cardMessageIdMap = new Map();
325
+ dispatchModeResolver;
274
326
  static PROACTIVE_ALLOW_TYPES = new Set([
275
327
  'text', 'quote', 'image', 'video', 'voice', 'file', 'json',
276
328
  'merge', 'link', 'location', 'personal_card',
277
329
  ]);
278
- // Reconnect state (TS-layer fallback, on top of SDK auto_reconnect)
330
+ // Reconnect state
331
+ // SDK 自己跑无限指数退避(1s → 5min);TS 层只在 SDK 够不到的两类场景下接管:
332
+ // 1. flap:短命 connected 反复出现(SDK 不记忆跨轮 base delay,会从 1s 重新开始)
333
+ // 2. terminal_failed with kick reason:直接 5min 后重试,不刷屏
279
334
  intentionalDisconnect = false;
280
- reconnectAttempt = 0;
281
335
  reconnectTimer = null;
282
- static RECONNECT_DELAYS = [60, 120, 300, 600]; // seconds
336
+ reconnectGeneration = 0; // 防止并发 initClient:每次 takeoverReconnect 递增,回调中校验
283
337
  onChannelDown;
284
- // SDK reconnect throttling avoid log spam when SDK enters tight reconnect loop
338
+ // initClient concurrency guard: 防止多个 initClient 并发执行
339
+ initInProgress = false;
340
+ // Flap detection: 连接寿命 < FLAP_WINDOW_MS 累计 FLAP_THRESHOLD 次,判定为持续被踢
341
+ connectedAt = 0;
342
+ flapCount = 0;
343
+ static FLAP_WINDOW_MS = 30_000;
344
+ static FLAP_THRESHOLD = 3;
345
+ // 接管后的统一退避时间(kicked / flap / terminal_failed)
346
+ static TAKEOVER_DELAY_MS = 5 * 60 * 1000; // 5min
347
+ // 一般 terminal_failed(非 kick)的兜底退避
348
+ static FALLBACK_DELAY_MS = 60 * 1000; // 1min
349
+ // SDK reconnect logging throttle(避免 attempt 自增刷屏)
285
350
  lastReconnectLogTime = 0;
286
351
  lastReconnectLogAttempt = 0;
287
- static RECONNECT_LOG_INTERVAL = 60_000; // log at most every 60s
288
- static RECONNECT_LOG_STEP = 100; // or every 100 attempts
289
- static SDK_RECONNECT_GIVEUP = 50; // force TS-layer fallback after this many SDK attempts
352
+ static RECONNECT_LOG_INTERVAL = 60_000;
353
+ static RECONNECT_LOG_STEP = 100;
354
+ // AID 连接状态(供 status 命令聚合展示)
355
+ aidState;
356
+ aidStatsCollector;
290
357
  constructor(config) {
291
358
  this.config = config;
292
359
  if (config.aunTrace) {
293
- this.rotateTraceIfNeeded();
294
- logger.info(`${this.logPrefix()} Trace logging enabled (hourly rotation, 12h retention): ${resolvePaths().logs}/aun-YYYYMMDD-HH.log`);
295
- }
360
+ this.traceWriter = new LogWriter({
361
+ baseName: 'aun',
362
+ logDir: resolvePaths().logs,
363
+ rotation: 'hourly',
364
+ retention: { hours: 12 },
365
+ });
366
+ logger.info(`${this.logPrefix()} Trace logging enabled (hourly rotation, 12h retention): ${this.traceWriter.activePath()}`);
367
+ }
368
+ this.aidState = {
369
+ aid: config.aid,
370
+ agentName: config.agentName ?? '<unknown>',
371
+ channelName: config.channelName ?? 'aun',
372
+ status: 'disabled',
373
+ reconnectCount: 0,
374
+ flapCount: 0,
375
+ };
376
+ }
377
+ /** Snapshot of AID connection state for status / IPC aggregation */
378
+ getAidState() {
379
+ return { ...this.aidState, flapCount: this.flapCount };
380
+ }
381
+ setAidStatsCollector(collector) {
382
+ this.aidStatsCollector = collector;
383
+ }
384
+ setAidStatus(status, extra) {
385
+ this.aidState.status = status;
386
+ if (extra)
387
+ Object.assign(this.aidState, extra);
296
388
  }
297
389
  async connect() {
298
390
  this.intentionalDisconnect = false;
299
- this.reconnectAttempt = 0;
391
+ this.flapCount = 0;
392
+ this.connectedAt = 0;
393
+ this.setAidStatus('reconnecting', { lastAttemptAt: Date.now() });
300
394
  await this.initClient();
395
+ this.startOutboxTimer();
301
396
  }
302
397
  async initClient() {
398
+ // 防止并发 initClient(sendMessage 触发 + timer 触发同时进入)
399
+ if (this.initInProgress) {
400
+ logger.info(`${this.logPrefix()} initClient already in progress, skipping`);
401
+ return;
402
+ }
403
+ this.initInProgress = true;
404
+ try {
405
+ await this._initClientInner();
406
+ }
407
+ finally {
408
+ this.initInProgress = false;
409
+ }
410
+ }
411
+ async _initClientInner() {
303
412
  // Clean up existing client if any
304
413
  if (this.client) {
305
414
  this.trace('OUT', 'client.close', { reason: 'initClient' });
@@ -364,6 +473,11 @@ export class AUNChannel {
364
473
  // trace is handled inside handleConnectionState with throttling
365
474
  this.handleConnectionState(data);
366
475
  });
476
+ // gateway 被踢/服务端主动断开(含同槽位互踢的 self/new extra_info)
477
+ this.client.on('gateway.disconnect', (data) => {
478
+ this.trace('IN', 'gateway.disconnect', data);
479
+ this.handleGatewayDisconnect(data);
480
+ });
367
481
  this.client.on('message.recalled', (data) => {
368
482
  this.trace('IN', 'message.recalled', data);
369
483
  if (data && typeof data === 'object') {
@@ -424,6 +538,7 @@ export class AUNChannel {
424
538
  accessToken = this.config.accessToken || process.env.AUN_ACCESS_TOKEN || '';
425
539
  if (!accessToken) {
426
540
  logger.error(`${this.logPrefix()} No accessToken fallback available, scheduling retry`);
541
+ this.setAidStatus('failed', { lastError: `${errName}: ${errMsg}`.slice(0, 80) });
427
542
  this.scheduleReconnect();
428
543
  throw new Error('Authentication failed and no accessToken fallback available');
429
544
  }
@@ -431,15 +546,26 @@ export class AUNChannel {
431
546
  }
432
547
  // Connect (SDK auto_reconnect handles transient failures)
433
548
  try {
434
- this.trace('OUT', 'client.connect', { gateway: this.client._gatewayUrl });
435
- await this.client.connect({ access_token: accessToken, gateway: this.client._gatewayUrl }, { auto_reconnect: true, retry: { max_attempts: 5, initial_delay: 1.0, max_delay: 30.0 } });
549
+ const extraInfo = buildConnectExtraInfo({
550
+ aid: this.config.aid,
551
+ agentName: this.config.agentName,
552
+ channelName: this.config.channelName,
553
+ });
554
+ this.trace('OUT', 'client.connect', { gateway: this.client._gatewayUrl, extra_info: extraInfo });
555
+ await this.client.connect({ access_token: accessToken, gateway: this.client._gatewayUrl, extra_info: extraInfo },
556
+ // max_attempts=0 = 无限重试(与 Go/Python 对齐),交由 SDK 自己跑指数退避
557
+ // initial_delay=1s,max_delay=300s(5min 封顶)
558
+ { auto_reconnect: true, retry: { max_attempts: 0, initial_delay: 1.0, max_delay: 300.0 } });
436
559
  this.trace('OUT', 'client.connect.ok', { aid: this.client.aid });
437
560
  this._aid = this.client.aid ?? undefined;
438
561
  const deviceId = this.client._device_id ?? '';
439
562
  this._chatId = this._aid ? `${this._aid}:${deviceId}:` : '';
440
563
  this._selfName = this.loadSelfName(aidName);
564
+ if (this._selfName && this.aidStatsCollector)
565
+ this.aidStatsCollector.setSelfName(this.config.aid, this._selfName);
441
566
  this.connected = true;
442
- this.reconnectAttempt = 0;
567
+ this.connectedAt = Date.now();
568
+ this.setAidStatus('connected', { lastConnectedAt: Date.now(), lastError: undefined, gatewayUrl: this.client?._gatewayUrl });
443
569
  // Workaround: SDK e2ee uses _identity.cert for sender_cert_fingerprint;
444
570
  // if cert is missing, it falls back to public key SPKI fingerprint which
445
571
  // causes peer cert lookup failures. Backfill from keystore if needed.
@@ -452,43 +578,43 @@ export class AUNChannel {
452
578
  }
453
579
  }
454
580
  logger.info(`${this.logPrefix()} Connected as ${this._aid}`);
581
+ appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'connected', aid: this.config.aid, gateway: this.client._gatewayUrl });
455
582
  // Send welcome message to owner after first connection
456
583
  await this.sendWelcomeMessage();
457
584
  }
458
585
  catch (e) {
459
586
  this.trace('OUT', 'client.connect.error', { error: String(e) });
460
587
  logger.error(`${this.logPrefix()} Connection failed: ${e}`);
588
+ this.setAidStatus('failed', { lastError: String(e).slice(0, 80) });
461
589
  this.scheduleReconnect();
462
590
  throw e;
463
591
  }
464
592
  }
465
593
  async sendWelcomeMessage() {
466
594
  try {
467
- const owner = this.config.owner;
468
- if (!owner) {
469
- logger.info(`${this.logPrefix()} No owner configured, skipping welcome message`);
470
- return;
471
- }
472
- // Check agent.md initialized field
473
595
  const aid = this.config.aid;
474
596
  const aidName = aid.startsWith('@') ? aid.slice(1) : aid;
475
- const agentMdPath = path.join(os.homedir(), '.aun', 'AIDs', aidName, 'agent.md');
476
- if (!fs.existsSync(agentMdPath)) {
477
- logger.warn(`${this.logPrefix()} agent.md not found, skipping welcome message`);
597
+ // Read initialized + owners from per-agent config.json
598
+ // (config.json 是 owner 的真相来源——auto-bind 后会更新这里,但 this.config 是
599
+ // channel 启动时的快照,不会自动同步)
600
+ const agentConfig = loadAgent(aidName);
601
+ if (!agentConfig) {
602
+ logger.warn(`${this.logPrefix()} agent config not found for ${aidName}, skipping welcome message`);
478
603
  return;
479
604
  }
480
- const agentMdContent = fs.readFileSync(agentMdPath, 'utf-8');
481
- const match = agentMdContent.match(/^---\n([\s\S]*?)\n---/);
482
- if (!match) {
483
- logger.warn(`${this.logPrefix()} agent.md frontmatter not found`);
605
+ if (agentConfig.initialized === true) {
606
+ logger.info(`${this.logPrefix()} Agent already initialized, skipping welcome message`);
484
607
  return;
485
608
  }
486
- const frontmatter = match[1];
487
- const initializedMatch = frontmatter.match(/^initialized:\s*(true|false)/m);
488
- if (!initializedMatch || initializedMatch[1] === 'true') {
489
- logger.info(`${this.logPrefix()} Agent already initialized, skipping welcome message`);
609
+ const owner = agentConfig.owners?.[0] ?? this.config.owner;
610
+ if (!owner) {
611
+ logger.info(`${this.logPrefix()} No owner configured, skipping welcome message (will retry after auto-bind)`);
490
612
  return;
491
613
  }
614
+ const agentMdPath = path.join(os.homedir(), '.aun', 'AIDs', aidName, 'agent.md');
615
+ const existingAgentMd = fs.existsSync(agentMdPath) ? fs.readFileSync(agentMdPath, 'utf-8') : '';
616
+ const existingFrontmatterMatch = existingAgentMd.match(/^---\n([\s\S]*?)\n---/);
617
+ const existingFrontmatter = existingFrontmatterMatch?.[1] ?? '';
492
618
  // Fetch owner's agent.md to derive name and validate type
493
619
  const ownerInfo = await this.fetchPeerInfo(owner);
494
620
  if (ownerInfo.type !== null && ownerInfo.type !== 'human') {
@@ -497,26 +623,18 @@ export class AUNChannel {
497
623
  // Name: prefer existing agent.md name if user has customized it,
498
624
  // otherwise generate "{ownerName}的Evol助手 ({aidLabel})" for disambiguation
499
625
  const ownerAidClean = owner.startsWith('@') ? owner.slice(1) : owner;
500
- let ownerDisplayName;
501
- if (ownerInfo.name) {
502
- ownerDisplayName = ownerInfo.name.slice(0, 12);
503
- }
504
- else {
505
- ownerDisplayName = ownerAidClean.split('.')[0].slice(0, 12);
506
- }
507
- // Check if init wrote a meaningful name (vs just the aid first label default)
508
- const currentNameMatch = frontmatter.match(/^name:\s*"?([^"\n]+)/m);
626
+ const ownerDisplayName = (ownerInfo.name || ownerAidClean.split('.')[0]).slice(0, 12);
627
+ const currentNameMatch = existingFrontmatter.match(/^name:\s*"?([^"\n]+)/m);
509
628
  const currentName = currentNameMatch?.[1]?.trim();
510
629
  const aidLabel = aidName.split('.')[0];
511
630
  let agentDisplayName;
512
631
  if (currentName && currentName !== aidLabel) {
513
- // User or previous init set a custom name — keep it
514
632
  agentDisplayName = currentName;
515
633
  }
516
634
  else {
517
635
  agentDisplayName = `${ownerDisplayName}的Evol助手 (${aidLabel})`;
518
636
  }
519
- // Generate new agent.md with proper fields
637
+ // Generate new agent.md (no `initialized` frontmatter — that's now in config.json)
520
638
  const newAgentMd = `---
521
639
  aid: "${aid}"
522
640
  name: "${agentDisplayName}"
@@ -527,7 +645,6 @@ tags:
527
645
  - evolclaw
528
646
  - ai-agent
529
647
  - gateway
530
- initialized: true
531
648
  ---
532
649
 
533
650
  # ${agentDisplayName}
@@ -535,8 +652,9 @@ initialized: true
535
652
  EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
536
653
  `;
537
654
  // Write locally
655
+ fs.mkdirSync(path.dirname(agentMdPath), { recursive: true });
538
656
  fs.writeFileSync(agentMdPath, newAgentMd, 'utf-8');
539
- logger.info(`${this.logPrefix()} Updated agent.md with initialized=true`);
657
+ logger.info(`${this.logPrefix()} Updated agent.md for ${aidName}`);
540
658
  // Publish to AUN network via auth.uploadAgentMd
541
659
  try {
542
660
  await this.client.auth.uploadAgentMd(newAgentMd);
@@ -581,6 +699,18 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
581
699
  persist_required: true,
582
700
  });
583
701
  logger.info(`${this.logPrefix()} Welcome message sent to owner: ${owner}`);
702
+ // Mark agent as initialized in config.json (replaces old agent.md frontmatter flag)
703
+ try {
704
+ const fresh = loadAgent(aidName);
705
+ if (fresh) {
706
+ fresh.initialized = true;
707
+ saveAgent(fresh);
708
+ logger.info(`${this.logPrefix()} Marked ${aidName} as initialized in config.json`);
709
+ }
710
+ }
711
+ catch (e) {
712
+ logger.warn(`${this.logPrefix()} Failed to update initialized flag in config.json: ${e}`);
713
+ }
584
714
  }
585
715
  catch (e) {
586
716
  logger.warn(`${this.logPrefix()} Failed to send welcome message: ${e}`);
@@ -651,7 +781,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
651
781
  const msg = data;
652
782
  const fromAid = msg.from ?? '';
653
783
  const payload = msg.payload ?? '';
654
- const text = this.extractTextPayload(payload);
784
+ const text = this.extractTextPayload(payload, fromAid);
655
785
  const taskId = typeof payload === 'object' && payload !== null ? payload.thread_id : undefined;
656
786
  const messageId = msg.message_id ?? '';
657
787
  const seq = msg.seq;
@@ -695,17 +825,22 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
695
825
  finalText = parts.join('\n\n');
696
826
  }
697
827
  }
698
- // Extract chat_id from payload for multi-instance routing (falls back to fromAid)
699
- const chatId = (typeof payload === 'object' && payload !== null && payload.chat_id)
700
- ? String(payload.chat_id)
701
- : fromAid;
828
+ // 私聊 channelId = 对端 AID(不再读 payload.chat_id device 三段式)
829
+ // device_id SDK 内部多实例去重用,evolclaw session 层面跨端共享会话
830
+ const chatId = fromAid;
702
831
  const peerInfo = await this.fetchPeerInfo(fromAid);
703
832
  const shortAid = this.getShortAid(fromAid);
704
833
  const displayName = peerInfo.name || shortAid;
705
834
  // 详细 dispatch 决策日志:记录消息为何被路由到 agent
706
835
  const p2pPayloadType = (payload && typeof payload === 'object') ? payload.type ?? '' : '';
707
836
  logger.info(`${this.logPrefix()} P2P dispatch decision: mid=${messageId} from=${shortAid}(${displayName}) peerType=${peerInfo.type || 'unknown'} payloadType=${p2pPayloadType} chatId=${chatId} encrypt=${msgEncrypted} textPreview=${JSON.stringify(text.slice(0, 80))}`);
837
+ // action_card_reply 已在 extractTextPayload 中消费,不分发给 agent
838
+ if (p2pPayloadType === 'action_card_reply')
839
+ return;
708
840
  logger.info(`${this.logPrefix()} P2P dispatched: from=${shortAid}(${displayName}) mid=${messageId} encrypt=${msgEncrypted} text=${finalText.slice(0, 60)}`);
841
+ appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_in', aid: this.config.aid, from: fromAid, msgId: messageId, kind: 'text', len: finalText.length });
842
+ const isSystemP2P = p2pPayloadType === 'event';
843
+ this.aidStatsCollector?.recordInbound(this.config.aid, fromAid, Buffer.byteLength(finalText, 'utf-8'), finalText, isSystemP2P);
709
844
  const replyContext = { metadata: { encrypted: msgEncrypted } };
710
845
  if (taskId)
711
846
  replyContext.threadId = taskId;
@@ -730,7 +865,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
730
865
  const groupId = msg.group_id ?? '';
731
866
  const senderAid = msg.sender_aid ?? '';
732
867
  const payload = msg.payload ?? '';
733
- const text = this.extractTextPayload(payload);
868
+ const text = this.extractTextPayload(payload, groupId);
734
869
  const taskId = typeof payload === 'object' && payload !== null ? payload.thread_id : undefined;
735
870
  const messageId = msg.message_id ?? '';
736
871
  const seq = msg.seq;
@@ -749,7 +884,37 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
749
884
  logger.debug(`${this.logPrefix()} Group dropped: own message (group=${groupId} mid=${messageId})`);
750
885
  return;
751
886
  }
752
- // ── proactive 模式入站白名单 ──
887
+ // echo 快速通道:连通性测试要尽量低延迟,命中后绕过所有 await(sessionModeResolver / 后续 mention 过滤)
888
+ {
889
+ const firstLineFast = text.split('\n')[0] || '';
890
+ const hasEvolClawTrace = /\[EvolClaw\.(receive|reply|agent)\]/.test(text);
891
+ if (/echo/i.test(firstLineFast) && firstLineFast.trim().length <= 10 && !hasEvolClawTrace) {
892
+ this.acknowledgeImmediately(messageId, seq);
893
+ const msgEncryptedFast = !!(msg.e2ee);
894
+ const peerInfo = this.peerInfoCached(senderAid);
895
+ const shortAid = this.getShortAid(senderAid);
896
+ const displayName = peerInfo?.name || shortAid;
897
+ const createdAt = data.created_at;
898
+ if (!peerInfo)
899
+ this.prefetchPeerInfo(senderAid);
900
+ this.handleEcho({
901
+ channelId: groupId,
902
+ userId: senderAid,
903
+ text,
904
+ chatType: 'group',
905
+ messageId,
906
+ peerName: displayName,
907
+ peerType: peerInfo?.type || 'unknown',
908
+ seq,
909
+ replyContext: this.buildGroupReplyContext(undefined, senderAid, msgEncryptedFast),
910
+ createdAt,
911
+ });
912
+ return;
913
+ }
914
+ }
915
+ // ── proactive 模式 payload 类型白名单 ──
916
+ // 仅做类型层面的防噪(task.update / status.ping 等信号类消息不进 Agent);
917
+ // mention 过滤统一交给后面的 dispatchMode 一段处理(避免双层语义)。
753
918
  if (this.sessionModeResolver) {
754
919
  const sessionMode = await this.sessionModeResolver(groupId).catch(() => undefined);
755
920
  if (sessionMode === 'proactive') {
@@ -760,36 +925,58 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
760
925
  logger.info(`${this.logPrefix()} Group dropped (proactive deny): type=${payloadType} group=${groupId} sender=${senderAid} mid=${messageId}`);
761
926
  return;
762
927
  }
763
- const rawText = typeof payloadObj?.text === 'string' ? payloadObj.text : '';
764
- const rawMentions = Array.isArray(payloadObj?.mentions) ? payloadObj.mentions : [];
765
- const mentionAids = this.extractMentionAids(rawMentions);
766
- const mentionsSelf = !!this._aid && (this.hasExplicitMention(rawText, this._aid) || mentionAids.includes(this._aid));
767
- // @all 仅认结构化 mentions(payload.mentions),不扫描正文 — 避免引述性 "@all" 误判
768
- const mentionsAll = this.hasMentionAll(rawMentions);
769
- if (!mentionsSelf && !mentionsAll) {
770
- this.acknowledgeImmediately(messageId, seq);
771
- logger.info(`${this.logPrefix()} Group dropped (proactive whitelist): type=${payloadType} group=${groupId} sender=${senderAid} mid=${messageId} textPreview=${JSON.stringify(rawText.slice(0, 80))}`);
772
- return;
773
- }
774
928
  }
775
929
  }
776
930
  // 记录入站消息加密状态,透传到出站 ReplyContext
777
931
  const msgEncrypted = !!(msg.e2ee);
778
932
  if (!msgEncrypted)
779
933
  this.plaintextRecv++;
780
- // dispatch_mode from server tells agent how to work in this group
781
- const dispatchMode = msg.dispatch_mode ?? payload?.dispatch_mode ?? 'mention';
934
+ // dispatch_mode: 本地设置优先,fallback 到服务器参数
935
+ const serverDispatchMode = msg.dispatch_mode ?? payload?.dispatch_mode ?? 'mention';
936
+ const localDispatchMode = this.dispatchModeResolver
937
+ ? await this.dispatchModeResolver(groupId).catch(() => undefined)
938
+ : undefined;
939
+ const dispatchMode = localDispatchMode || serverDispatchMode;
782
940
  const mentionedSelf = this._aid
783
941
  ? (this.hasExplicitMention(text, this._aid) || payloadMentions.includes(this._aid))
784
942
  : false;
785
943
  // @all 仅认结构化 mentions(payload.mentions),不扫描正文 — 避免引述性 "@all" 误判
786
944
  const mentionedAll = payloadMentions.includes('all');
787
- // In mention mode, only respond when explicitly mentioned; in broadcast mode, respond to all
788
- if (dispatchMode === 'mention' && !mentionedSelf && !mentionedAll) {
945
+ // Echo 机制优先于 mention 过滤:消息第一行包含 echo 时触发
946
+ // 包含 [EvolClaw.xxx] trace 说明已被本系统处理过,是回声的回声,丢弃防止链式爆炸
947
+ const firstLineGroup = text.split('\n')[0] || '';
948
+ const hasEvolClawTraceGroup = /\[EvolClaw\.(receive|reply|agent)\]/.test(text);
949
+ if (/echo/i.test(firstLineGroup) && !hasEvolClawTraceGroup) {
950
+ // 短 echo(≤10 字符)已在前面的快速通道命中并 return,这里只处理长 echo
951
+ // >10 字符:追加 trace,存 pending echo,跳过 mention 过滤继续走 Agent 流程
952
+ const echoTs = () => {
953
+ const d = new Date();
954
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
955
+ };
956
+ let echoText = text;
957
+ echoText += `\n${echoTs()} [EvolClaw.receive] from=${senderAid} mid=${messageId} chat=group self=${this._aid || 'unknown'} conn_uptime=${this.connectedAt ? Math.round((Date.now() - this.connectedAt) / 1000) + 's' : 'unknown'}`;
958
+ this.pendingEchoMessages.set(messageId, {
959
+ text: echoText,
960
+ channelId: groupId,
961
+ context: this.buildGroupReplyContext(undefined, senderAid, msgEncrypted),
962
+ receiveTs: Date.now(),
963
+ });
964
+ // 继续走正常 Agent 流程(下面的代码会 dispatch)
965
+ }
966
+ else if (/echo/i.test(firstLineGroup) && hasEvolClawTraceGroup) {
967
+ // 回声炸弹:已被 trace 过的 echo,直接丢弃
789
968
  this.acknowledgeImmediately(messageId, seq);
790
- logger.info(`${this.logPrefix()} Group dropped: unmentioned in mention-mode (group=${groupId} sender=${senderAid} mid=${messageId} textPreview=${JSON.stringify(text.slice(0, 80))})`);
969
+ logger.info(`${this.logPrefix()} Group dropped: echo bomb (already-traced group=${groupId} sender=${senderAid} mid=${messageId})`);
791
970
  return;
792
971
  }
972
+ else {
973
+ // 非 echo 消息:正常 mention 过滤
974
+ if (dispatchMode === 'mention' && !mentionedSelf && !mentionedAll) {
975
+ this.acknowledgeImmediately(messageId, seq);
976
+ logger.info(`${this.logPrefix()} Group dropped: unmentioned in mention-mode (group=${groupId} sender=${senderAid} mid=${messageId} textPreview=${JSON.stringify(text.slice(0, 80))})`);
977
+ return;
978
+ }
979
+ }
793
980
  const strippedText = this.stripSelfMentionIfOnly(text, this._aid);
794
981
  // Detect attachments before the empty-text guard
795
982
  const rawAttachments = Array.isArray(payload?.attachments)
@@ -840,9 +1027,15 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
840
1027
  ? (structMentionSelf ? 'mention.self(struct)' : 'mention.self(text)')
841
1028
  : `${dispatchMode}.no-mention`;
842
1029
  logger.info(`${this.logPrefix()} Group dispatch decision: mid=${messageId} group=${groupId} sender=${shortAid}(${displayName}) peerType=${peerInfo.type || 'unknown'} payloadType=${payloadType} dispatchMode=${dispatchMode} reason=${reason} structMentions=${JSON.stringify(payloadMentions)} textMentionSelf=${textMentionSelf} textMentionAll=${textMentionAll} structMentionSelf=${structMentionSelf} structMentionAll=${structMentionAll} encrypt=${msgEncrypted} textPreview=${JSON.stringify(text.slice(0, 80))}`);
1030
+ // action_card_reply 已在 extractTextPayload 中消费,不分发给 agent
1031
+ if (payloadType === 'action_card_reply')
1032
+ return;
843
1033
  logger.info(`${this.logPrefix()} Group dispatched: group=${groupId} sender=${shortAid}(${displayName}) mode=${dispatchMode} mid=${messageId} text=${finalText.slice(0, 60)}`);
1034
+ 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 });
1035
+ this.aidStatsCollector?.recordInbound(this.config.aid, senderAid, Buffer.byteLength(finalText, 'utf-8'), finalText, payloadType === 'event');
844
1036
  this.dispatchMessage({
845
1037
  channelId: groupId,
1038
+ groupId,
846
1039
  userId: senderAid,
847
1040
  peerName: displayName || undefined,
848
1041
  peerType: peerInfo.type || 'unknown',
@@ -867,6 +1060,35 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
867
1060
  this.messageSeqMap.set(event.messageId, event.seq);
868
1061
  }
869
1062
  }
1063
+ // Echo 机制:消息第一行包含 "echo"(不区分大小写)且原始内容 ≤10 字符时,直接回声
1064
+ // 包含 [EvolClaw.xxx] trace 说明已被本系统处理过,是回声的回声,丢弃防止链式爆炸
1065
+ const firstLine = event.text.split('\n')[0] || '';
1066
+ const hasEvolClawTracePrivate = /\[EvolClaw\.(receive|reply|agent)\]/.test(event.text);
1067
+ if (/echo/i.test(firstLine) && firstLine.trim().length <= 10 && !hasEvolClawTracePrivate) {
1068
+ this.handleEcho(event);
1069
+ return;
1070
+ }
1071
+ // 回声炸弹:已被本系统 trace 过的 echo,直接丢弃
1072
+ if (/echo/i.test(firstLine) && hasEvolClawTracePrivate) {
1073
+ logger.info(`${this.logPrefix()} Dropped: echo bomb (already-traced mid=${event.messageId} chat=${event.chatType})`);
1074
+ return;
1075
+ }
1076
+ // 长 echo(>10 字符):存 pending,继续交给 agent 处理
1077
+ if (/echo/i.test(firstLine) && firstLine.trim().length > 10 && !hasEvolClawTracePrivate) {
1078
+ const echoTs = () => {
1079
+ const d = new Date();
1080
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
1081
+ };
1082
+ let echoText = event.text;
1083
+ echoText += `\n${echoTs()} [EvolClaw.receive] from=${event.userId}${event.peerName ? `(${event.peerName})` : ''} mid=${event.messageId} chat=${event.chatType} self=${this._aid || 'unknown'} conn_uptime=${this.connectedAt ? Math.round((Date.now() - this.connectedAt) / 1000) + 's' : 'unknown'}`;
1084
+ this.pendingEchoMessages.set(event.messageId, {
1085
+ text: echoText,
1086
+ channelId: event.channelId,
1087
+ context: event.replyContext ? { metadata: event.replyContext.metadata } : undefined,
1088
+ receiveTs: Date.now(),
1089
+ });
1090
+ logger.info(`${this.logPrefix()} [Echo] long echo stored: mid=${event.messageId} channelId=${event.channelId}`);
1091
+ }
870
1092
  if (!this.messageHandler)
871
1093
  return;
872
1094
  const mentionObjects = event.mentions?.map(aid => ({ userId: aid }));
@@ -878,7 +1100,10 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
878
1100
  }
879
1101
  this.messageHandler({
880
1102
  channelId: event.channelId || '',
1103
+ channelType: 'aun',
881
1104
  content: event.text || '',
1105
+ selfId: this._aid,
1106
+ groupId: event.groupId,
882
1107
  chatType: event.chatType,
883
1108
  peerId: event.userId || event.channelId || '',
884
1109
  peerName: event.peerName,
@@ -891,6 +1116,71 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
891
1116
  logger.error(`${this.logPrefix()} Message handler error:`, err);
892
1117
  });
893
1118
  }
1119
+ handleEcho(event) {
1120
+ const ts = () => {
1121
+ const d = new Date();
1122
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
1123
+ };
1124
+ // 在收到的消息文本末尾追加 trace 行(只追加,不修改原文)
1125
+ let echoText = event.text;
1126
+ echoText += `\n${ts()} [EvolClaw.receive] from=${event.userId}${event.peerName ? `(${event.peerName})` : ''} mid=${event.messageId} chat=${event.chatType} self=${this._aid || 'unknown'} conn_uptime=${this.connectedAt ? Math.round((Date.now() - this.connectedAt) / 1000) + 's' : 'unknown'}`;
1127
+ echoText += `\n${ts()} [EvolClaw.reply] echo回声发出 conn_uptime=${this.connectedAt ? Math.round((Date.now() - this.connectedAt) / 1000) + 's' : 'unknown'}`;
1128
+ if (Buffer.byteLength(echoText, 'utf-8') > 4096) {
1129
+ echoText += `\n[TRUNCATED]`;
1130
+ }
1131
+ const replyTarget = event.channelId;
1132
+ // 不传 peerId,避免 sendMessage 在头部追加 @peer
1133
+ const context = { metadata: event.replyContext?.metadata };
1134
+ const sendStart = Date.now();
1135
+ this.isEchoSending = true;
1136
+ this.sendMessage(replyTarget, echoText, context).then(() => {
1137
+ this.isEchoSending = false;
1138
+ const elapsed = Date.now() - sendStart;
1139
+ logger.info(`${this.logPrefix()} Echo reply sent to ${replyTarget} (${elapsed}ms)`);
1140
+ }).catch(e => {
1141
+ this.isEchoSending = false;
1142
+ logger.error(`${this.logPrefix()} Echo reply failed: ${e}`);
1143
+ });
1144
+ }
1145
+ /**
1146
+ * 处理 SDK 'gateway.disconnect' 事件 — 服务端主动断开(含同槽位互踢)。
1147
+ *
1148
+ * 当另一个进程用同 AID + 同 slot 抢连接时,老连接会被踢,detail 字段含:
1149
+ * - self_extra_info: 本进程当时 connect 上报的内容(可证明"是我自己被踢了")
1150
+ * - new_extra_info: 挤掉本连接的新进程上报的内容(指出"谁踢的我")
1151
+ *
1152
+ * 把这些信息打到 logger.warn,便于通过 evolclaw watch / 日志查看。
1153
+ */
1154
+ handleGatewayDisconnect(data) {
1155
+ if (!data || typeof data !== 'object')
1156
+ return;
1157
+ const d = data;
1158
+ const code = d.code;
1159
+ const reason = d.reason ?? '';
1160
+ const detail = (d.detail && typeof d.detail === 'object' && !Array.isArray(d.detail)) ? d.detail : {};
1161
+ const selfExtra = detail.self_extra_info;
1162
+ const newExtra = detail.new_extra_info;
1163
+ const fmtExtra = (e) => {
1164
+ if (!e || typeof e !== 'object')
1165
+ return '<none>';
1166
+ const obj = e;
1167
+ const parts = [];
1168
+ const keys = ['app', 'version', 'pid', 'started_at_iso', 'hostname', 'launched_by', 'evolclaw_home', 'agent_name', 'channel_name'];
1169
+ for (const k of keys) {
1170
+ if (obj[k] !== undefined && obj[k] !== '')
1171
+ parts.push(`${k}=${String(obj[k])}`);
1172
+ }
1173
+ return parts.length ? parts.join(' ') : JSON.stringify(obj);
1174
+ };
1175
+ if (selfExtra || newExtra) {
1176
+ logger.warn(`${this.logPrefix()} 🥊 Kicked by another connection (code=${code} reason=${reason})`);
1177
+ logger.warn(`${this.logPrefix()} ↳ me : ${fmtExtra(selfExtra)}`);
1178
+ logger.warn(`${this.logPrefix()} ↳ them: ${fmtExtra(newExtra)}`);
1179
+ }
1180
+ else {
1181
+ logger.warn(`${this.logPrefix()} Server-initiated disconnect: code=${code} reason=${reason} detail=${JSON.stringify(detail)}`);
1182
+ }
1183
+ }
894
1184
  handleConnectionState(data) {
895
1185
  if (!data || typeof data !== 'object')
896
1186
  return;
@@ -902,56 +1192,181 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
902
1192
  logger.info(`[AUN][DIAG-STALE] connection.state event: state=${state} attempt=${data.attempt ?? '-'} | connected_before=${connectedBefore} sdk_state=${sdkState}`);
903
1193
  if (state === 'connected') {
904
1194
  this.connected = true;
905
- this.reconnectAttempt = 0;
1195
+ this.connectedAt = Date.now();
906
1196
  this.lastReconnectLogTime = 0;
907
1197
  this.lastReconnectLogAttempt = 0;
1198
+ this.setAidStatus('connected', { lastConnectedAt: Date.now(), lastError: undefined, gatewayUrl: this.client?._gatewayUrl });
908
1199
  this.trace('IN', 'connection.state', data);
909
1200
  logger.info(`${this.logPrefix()} Connected`);
1201
+ // 不在这里清 flapCount —— 短命连接一上来就会触发本分支,
1202
+ // 必须等 disconnected 时根据 lifetime 决定是否清零
1203
+ this.drainOutbox();
910
1204
  }
911
- else if (state === 'disconnected') {
912
- this.connected = false;
913
- this.trace('IN', 'connection.state', data);
914
- logger.warn(`${this.logPrefix()} Disconnected: ${data.error ?? 'unknown'}`);
915
- }
916
- else if (state === 'reconnecting') {
1205
+ else if (state === 'disconnected' || state === 'reconnecting') {
1206
+ const wasConnected = this.connected;
917
1207
  this.connected = false;
918
- const attempt = data.attempt ?? 0;
919
- const now = Date.now();
920
- // Throttled logging: first attempt, every N attempts, or every M seconds
921
- const isFirst = attempt <= 1;
922
- const isStep = attempt - this.lastReconnectLogAttempt >= AUNChannel.RECONNECT_LOG_STEP;
923
- const isInterval = now - this.lastReconnectLogTime >= AUNChannel.RECONNECT_LOG_INTERVAL;
924
- if (isFirst || isStep || isInterval) {
925
- const suppressed = attempt - this.lastReconnectLogAttempt - 1;
926
- const suffix = suppressed > 0 ? `, ${suppressed} suppressed since last log` : '';
927
- logger.info(`${this.logPrefix()} SDK reconnecting (attempt ${attempt}${suffix})`);
928
- this.lastReconnectLogTime = now;
929
- this.lastReconnectLogAttempt = attempt;
1208
+ this.aidState.reconnectCount++;
1209
+ this.aidState.lastAttemptAt = Date.now();
1210
+ this.setAidStatus('reconnecting');
1211
+ // Flap 检测:仅在从 connected 状态过渡时统计
1212
+ if (wasConnected && this.connectedAt > 0) {
1213
+ const lifetime = Date.now() - this.connectedAt;
1214
+ this.connectedAt = 0;
1215
+ if (lifetime < AUNChannel.FLAP_WINDOW_MS) {
1216
+ this.flapCount++;
1217
+ logger.warn(`${this.logPrefix()} Flap #${this.flapCount}/${AUNChannel.FLAP_THRESHOLD}: connection lived ${lifetime}ms (< ${AUNChannel.FLAP_WINDOW_MS}ms)`);
1218
+ if (this.flapCount >= AUNChannel.FLAP_THRESHOLD && !this.intentionalDisconnect) {
1219
+ logger.error(`${this.logPrefix()} Persistent kick detected (${this.flapCount} flaps), taking over from SDK with ${AUNChannel.TAKEOVER_DELAY_MS / 1000}s backoff`);
1220
+ this.flapCount = 0;
1221
+ this.takeoverReconnect(AUNChannel.TAKEOVER_DELAY_MS, 'flap');
1222
+ return;
1223
+ }
1224
+ }
1225
+ else {
1226
+ // 连接稳定过 ≥ FLAP_WINDOW_MS,重置 flap 计数
1227
+ if (this.flapCount > 0) {
1228
+ logger.info(`${this.logPrefix()} Stable connection (lived ${lifetime}ms), resetting flap counter`);
1229
+ }
1230
+ this.flapCount = 0;
1231
+ }
1232
+ }
1233
+ if (state === 'disconnected') {
930
1234
  this.trace('IN', 'connection.state', data);
1235
+ logger.warn(`${this.logPrefix()} Disconnected: ${data.error ?? 'unknown'}`);
931
1236
  }
932
- // Detect runaway SDK reconnect loop: force disconnect and use TS-layer backoff
933
- if (attempt >= AUNChannel.SDK_RECONNECT_GIVEUP && !this.intentionalDisconnect) {
934
- logger.warn(`${this.logPrefix()} SDK reconnect stuck at attempt ${attempt}, forcing TS-layer reconnect with backoff`);
935
- this.connected = false;
936
- if (this.client) {
937
- this.trace('OUT', 'client.close', { reason: 'sdk_reconnect_stuck' });
938
- this.client.close().catch(() => { });
939
- this.client = null;
1237
+ else {
1238
+ // reconnecting:节流日志(SDK 自己已经在跑指数退避,不刷屏)
1239
+ const attempt = data.attempt ?? 0;
1240
+ const now = Date.now();
1241
+ const isFirst = attempt <= 1;
1242
+ const isStep = attempt - this.lastReconnectLogAttempt >= AUNChannel.RECONNECT_LOG_STEP;
1243
+ const isInterval = now - this.lastReconnectLogTime >= AUNChannel.RECONNECT_LOG_INTERVAL;
1244
+ if (isFirst || isStep || isInterval) {
1245
+ const suppressed = attempt - this.lastReconnectLogAttempt - 1;
1246
+ const suffix = suppressed > 0 ? `, ${suppressed} suppressed since last log` : '';
1247
+ logger.info(`${this.logPrefix()} SDK reconnecting (attempt ${attempt}${suffix})`);
1248
+ this.lastReconnectLogTime = now;
1249
+ this.lastReconnectLogAttempt = attempt;
1250
+ this.trace('IN', 'connection.state', data);
940
1251
  }
941
- this.scheduleReconnect();
942
1252
  }
943
1253
  }
944
1254
  else if (state === 'terminal_failed') {
945
1255
  this.connected = false;
1256
+ this.connectedAt = 0;
946
1257
  this.trace('IN', 'connection.state', data);
947
- const reason = data.reason ?? '';
948
- logger.error(`${this.logPrefix()} Terminal failure: ${data.error ?? 'unknown'}${reason ? ` (${reason})` : ''}`);
949
- if (!this.intentionalDisconnect) {
950
- this.scheduleReconnect();
1258
+ const d = data;
1259
+ const reason = d.reason ?? '';
1260
+ const error = d.error ?? 'unknown';
1261
+ if (this.intentionalDisconnect)
1262
+ return;
1263
+ // 被踢类(server kicked / close code 4001-4011)→ ERROR + 5min 长退避
1264
+ if (this.isKickReason(reason)) {
1265
+ logger.error(`${this.logPrefix()} Kicked by server: ${reason} (${error}), backing off ${AUNChannel.TAKEOVER_DELAY_MS / 1000}s`);
1266
+ this.setAidStatus('kicked', { lastError: `kicked: ${error}`.slice(0, 80) });
1267
+ this.takeoverReconnect(AUNChannel.TAKEOVER_DELAY_MS, 'kicked');
951
1268
  }
1269
+ else {
1270
+ // 其他 terminal failure(含 max_attempts_exhausted 兜底)→ 1min 后再试
1271
+ logger.error(`${this.logPrefix()} Terminal failure: ${error}${reason ? ` (${reason})` : ''}, retrying in ${AUNChannel.FALLBACK_DELAY_MS / 1000}s`);
1272
+ this.setAidStatus('failed', { lastError: `${error}`.slice(0, 80) });
1273
+ this.takeoverReconnect(AUNChannel.FALLBACK_DELAY_MS, 'terminal');
1274
+ }
1275
+ }
1276
+ }
1277
+ /** 判断 terminal_failed 的 reason 是否属于"被踢"类 */
1278
+ isKickReason(reason) {
1279
+ if (!reason)
1280
+ return false;
1281
+ const r = reason.toLowerCase();
1282
+ if (r.includes('kicked') || r.includes('kick'))
1283
+ return true;
1284
+ // close code 4001/4003/4008/4009/4010/4011 都是 SDK _NO_RECONNECT_CODES
1285
+ if (/close code 40(0[13]|0[89]|1[01])/.test(r))
1286
+ return true;
1287
+ return false;
1288
+ }
1289
+ /**
1290
+ * TS 层接管重连:force close 当前 SDK client,安排 delayMs 后重新 initClient。
1291
+ * 用于 flap / kicked / terminal_failed 三类场景,统一退避路径。
1292
+ */
1293
+ takeoverReconnect(delayMs, reason) {
1294
+ if (this.intentionalDisconnect)
1295
+ return;
1296
+ // 递增 generation,使任何正在进行的旧 initClient 回调失效
1297
+ const gen = ++this.reconnectGeneration;
1298
+ // 清掉已有 timer,避免叠加
1299
+ if (this.reconnectTimer) {
1300
+ clearTimeout(this.reconnectTimer);
1301
+ this.reconnectTimer = null;
1302
+ }
1303
+ // Force close SDK client,中断它内部的重连循环
1304
+ if (this.client) {
1305
+ this.trace('OUT', 'client.close', { reason: `takeover_${reason}` });
1306
+ this.client.close().catch(() => { });
1307
+ this.client = null;
952
1308
  }
1309
+ this.connected = false;
1310
+ const delaySec = Math.round(delayMs / 1000);
1311
+ logger.info(`${this.logPrefix()} Scheduling TS-layer reconnect (${reason}) in ${delaySec}s`);
1312
+ this.trace('OUT', 'reconnect.scheduled', { reason, delayMs, generation: gen });
1313
+ this.reconnectTimer = setTimeout(async () => {
1314
+ this.reconnectTimer = null;
1315
+ // 如果在等待期间又触发了新的 takeoverReconnect,本次作废
1316
+ if (gen !== this.reconnectGeneration) {
1317
+ logger.info(`${this.logPrefix()} TS-layer reconnect (${reason}) cancelled: generation stale (${gen} vs ${this.reconnectGeneration})`);
1318
+ return;
1319
+ }
1320
+ try {
1321
+ logger.info(`${this.logPrefix()} TS-layer reconnect (${reason}) starting...`);
1322
+ this.trace('OUT', 'reconnect.start', { reason, generation: gen });
1323
+ await this.initClient();
1324
+ // initClient 完成后再次校验 generation,防止 initClient 期间被新的 takeover 取代
1325
+ if (gen !== this.reconnectGeneration) {
1326
+ logger.info(`${this.logPrefix()} TS-layer reconnect (${reason}) succeeded but generation stale, closing stale client`);
1327
+ if (this.client) {
1328
+ this.client.close().catch(() => { });
1329
+ this.client = null;
1330
+ }
1331
+ this.connected = false;
1332
+ return;
1333
+ }
1334
+ this.trace('OUT', 'reconnect.ok', { reason, generation: gen });
1335
+ logger.info(`${this.logPrefix()} TS-layer reconnect (${reason}) succeeded`);
1336
+ }
1337
+ catch (err) {
1338
+ this.trace('OUT', 'reconnect.error', { reason, error: String(err), generation: gen });
1339
+ logger.error(`${this.logPrefix()} TS-layer reconnect (${reason}) failed: ${err}`);
1340
+ // initClient 内部已经在失败路径触发 scheduleReconnect 了,这里不重复
1341
+ }
1342
+ }, delayMs);
953
1343
  }
954
1344
  // ── Public API (same interface as before) ───────────────────
1345
+ setEventBus(bus) {
1346
+ // 重新订阅前先解掉旧的——避免 reload/重连后 listener 累积
1347
+ if (this.eventBus && this.ownerBoundHandler && typeof this.eventBus.unsubscribe === 'function') {
1348
+ this.eventBus.unsubscribe('channel:owner-bound', this.ownerBoundHandler);
1349
+ }
1350
+ this.ownerBoundHandler = null;
1351
+ this.eventBus = bus;
1352
+ if (bus && typeof bus.subscribe === 'function') {
1353
+ const handler = (event) => {
1354
+ if (event.channelName !== this.config.channelName)
1355
+ return;
1356
+ // sendWelcomeMessage 内部读 config.json 中最新的 owners[0],并幂等检查 initialized
1357
+ // 自身做 client 健康检查后再发
1358
+ if (!this.client) {
1359
+ logger.info(`${this.logPrefix()} owner-bound event received but client not connected; skip welcome retry`);
1360
+ return;
1361
+ }
1362
+ this.sendWelcomeMessage().catch(e => {
1363
+ logger.warn(`${this.logPrefix()} owner-bound welcome retry failed: ${e}`);
1364
+ });
1365
+ };
1366
+ bus.subscribe('channel:owner-bound', handler);
1367
+ this.ownerBoundHandler = handler;
1368
+ }
1369
+ }
955
1370
  onProjectPathRequest(provider) {
956
1371
  this.projectPathProvider = provider;
957
1372
  }
@@ -961,41 +1376,99 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
961
1376
  setSessionModeResolver(resolver) {
962
1377
  this.sessionModeResolver = resolver;
963
1378
  }
1379
+ setDispatchModeResolver(resolver) {
1380
+ this.dispatchModeResolver = resolver;
1381
+ }
964
1382
  onRecall(handler) {
965
1383
  this.recallHandler = handler;
966
1384
  }
967
1385
  async sendMessage(channelId, text, context) {
968
- // [DIAG-STALE] 进入 sendMessage 时记录 evolclaw connected 标志和 SDK _state,
969
- // 用于检测两者是否一致:若 connected=true 但 sdk_state != 'connected',即为 stale 状态
970
- const sdkStateOnEntry = this.client?._state ?? 'no-client';
971
- if (this.connected !== (sdkStateOnEntry === 'connected')) {
972
- logger.warn(`[AUN][DIAG-STALE] sendMessage entry MISMATCH: connected=${this.connected} sdk_state=${sdkStateOnEntry} channel=${channelId} text=${text.slice(0, 40)}`);
973
- }
974
- else {
975
- logger.debug(`[AUN][DIAG-STALE] sendMessage entry: connected=${this.connected} sdk_state=${sdkStateOnEntry} channel=${channelId}`);
976
- }
977
- if (!this.connected || !this.client) {
978
- logger.warn(`${this.logPrefix()} Cannot send: not connected`);
979
- return;
980
- }
981
1386
  if (!text?.trim()) {
982
1387
  logger.warn(`${this.logPrefix()} Attempted to send empty message, skipping`);
983
1388
  return;
984
1389
  }
1390
+ // 长 echo: agent 首次回复前先发 echo trace(echo 自身发送时跳过)
1391
+ if (!this.isEchoSending) {
1392
+ await this.flushPendingEcho(channelId);
1393
+ }
985
1394
  let finalText = text;
986
- // 多轮工具调用后的最终回复:仅在已有中间消息时添加前缀
987
1395
  if (context?.title && (this.sentCount.get(channelId) || 0) > 0) {
988
1396
  finalText = '最终回复\n' + text;
989
1397
  }
990
1398
  this.sentCount.set(channelId, (this.sentCount.get(channelId) || 0) + 1);
991
- // 群聊 @ 兜底:提示词已告知 agent 要 @,但如果 agent 没写,系统自动补上
992
1399
  if (this.isGroupId(channelId) && context?.peerId) {
993
1400
  if (!finalText.includes(`@${context.peerId}`)) {
994
1401
  finalText = `@${context.peerId} ` + finalText;
995
1402
  }
996
1403
  }
1404
+ // Write-ahead: persist to outbox before attempting send
1405
+ const entry = outbox.enqueue(this.config.aid, {
1406
+ channelId,
1407
+ type: 'text',
1408
+ text: finalText,
1409
+ context,
1410
+ });
1411
+ logger.debug(`${this.logPrefix()} Outbox enqueued: id=${entry.id} channel=${channelId} text=${finalText.slice(0, 40)}`);
1412
+ if (!this.connected || !this.client) {
1413
+ logger.warn(`${this.logPrefix()} Not connected, message queued in outbox (id=${entry.id}). Triggering reconnect.`);
1414
+ if (!this.reconnectTimer && !this.client) {
1415
+ this.initClient().catch(e => logger.error(`${this.logPrefix()} Reconnect from sendMessage failed: ${e}`));
1416
+ }
1417
+ return;
1418
+ }
1419
+ // Attempt immediate delivery
1420
+ const ok = await this.deliverTextEntry(entry);
1421
+ if (ok) {
1422
+ outbox.remove(this.config.aid, entry.id);
1423
+ }
1424
+ }
1425
+ async flushPendingEcho(channelId) {
1426
+ // 查找该 channelId 是否有 pending echo(长 echo 等待 agent 首次回复)
1427
+ for (const [key, echo] of this.pendingEchoMessages) {
1428
+ if (echo.channelId === channelId) {
1429
+ this.pendingEchoMessages.delete(key);
1430
+ logger.info(`${this.logPrefix()} [Echo] flushPendingEcho triggered: key=${key} channelId=${channelId} pendingCount=${this.pendingEchoMessages.size}`);
1431
+ const ts = () => {
1432
+ const d = new Date();
1433
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
1434
+ };
1435
+ const agentDuration = Date.now() - echo.receiveTs;
1436
+ let echoText = echo.text;
1437
+ echoText += `\n${ts()} [EvolClaw.agent] duration=${agentDuration}ms`;
1438
+ echoText += `\n${ts()} [EvolClaw.reply] echo回声发出 conn_uptime=${this.connectedAt ? Math.round((Date.now() - this.connectedAt) / 1000) + 's' : 'unknown'}`;
1439
+ if (Buffer.byteLength(echoText, 'utf-8') > 4096) {
1440
+ echoText += `\n[TRUNCATED]`;
1441
+ }
1442
+ // 直接投递 echo trace(不经过 sendMessage 避免 @peer 前缀和递归)
1443
+ if (this.connected && this.client) {
1444
+ const echoEntry = {
1445
+ id: `echo-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`,
1446
+ ts: Date.now(),
1447
+ aid: this.config.aid,
1448
+ channelId,
1449
+ type: 'text',
1450
+ text: echoText,
1451
+ ttl: 300_000,
1452
+ };
1453
+ const ok = await this.deliverTextEntry(echoEntry);
1454
+ if (!ok) {
1455
+ outbox.enqueue(this.config.aid, { channelId, type: 'text', text: echoText });
1456
+ }
1457
+ logger.info(`${this.logPrefix()} [Echo] long echo trace delivered=${ok} to ${channelId} (agent ${agentDuration}ms)`);
1458
+ }
1459
+ else {
1460
+ outbox.enqueue(this.config.aid, { channelId, type: 'text', text: echoText });
1461
+ logger.warn(`${this.logPrefix()} [Echo] not connected, echo trace queued in outbox`);
1462
+ }
1463
+ break;
1464
+ }
1465
+ }
1466
+ }
1467
+ async deliverTextEntry(entry) {
1468
+ const channelId = entry.channelId;
1469
+ const finalText = entry.text;
1470
+ const context = entry.context;
997
1471
  const payload = { type: 'text', text: finalText };
998
- // 出站群消息:从正文提取 @aid 填入结构化 mentions(与 aun CLI 对齐,方便对端按 mentions 过滤)
999
1472
  if (this.isGroupId(channelId)) {
1000
1473
  const extracted = this.extractMentionAidsFromText(finalText);
1001
1474
  if (extracted.length > 0)
@@ -1008,12 +1481,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1008
1481
  if (context?.metadata?.chatmode)
1009
1482
  payload.chatmode = context.metadata.chatmode;
1010
1483
  const isGroup = this.isGroupId(channelId);
1011
- // Multi-instance routing: channelId may be "aid:device_id:slot_id"
1012
- const colonIdx = channelId.indexOf(':');
1013
- const targetAid = colonIdx > 0 ? channelId.substring(0, colonIdx) : channelId;
1014
- if (colonIdx > 0) {
1015
- payload.chat_id = channelId;
1016
- }
1484
+ const targetAid = channelId;
1017
1485
  const encryptTarget = isGroup ? channelId : targetAid;
1018
1486
  const encrypt = context?.metadata?.encrypted != null
1019
1487
  ? !!(context.metadata.encrypted)
@@ -1034,6 +1502,8 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1034
1502
  }
1035
1503
  else {
1036
1504
  logger.info(`${this.logPrefix()} group.send ok: group=${channelId} mid=${result.message_id} encrypt=${encrypt} text=${finalText.slice(0, 60)}`);
1505
+ appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: channelId, msgId: result.message_id, kind: 'text', len: finalText.length, groupId: channelId });
1506
+ this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, Buffer.byteLength(finalText, 'utf-8'), finalText);
1037
1507
  }
1038
1508
  }
1039
1509
  else {
@@ -1044,8 +1514,11 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1044
1514
  }
1045
1515
  else {
1046
1516
  logger.info(`${this.logPrefix()} message.send ok: to=${this.peerLabel(targetAid)} mid=${result.message_id} encrypt=${encrypt} text=${finalText.slice(0, 60)}`);
1517
+ 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 });
1518
+ this.aidStatsCollector?.recordOutbound(this.config.aid, targetAid, Buffer.byteLength(finalText, 'utf-8'), finalText);
1047
1519
  }
1048
1520
  }
1521
+ return true;
1049
1522
  }
1050
1523
  catch (e) {
1051
1524
  if (encrypt && e instanceof E2EEError) {
@@ -1069,15 +1542,18 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1069
1542
  logger.warn(`${this.logPrefix()} message.send fallback returned no message_id: ${JSON.stringify(result)}`);
1070
1543
  }
1071
1544
  }
1545
+ return true;
1072
1546
  }
1073
1547
  catch (e2) {
1074
1548
  this.trace('OUT', 'send.fallback.error', { channelId, error: String(e2) });
1075
1549
  logger.error(`${this.logPrefix()} Plaintext fallback also failed to ${channelId}: ${e2}`);
1550
+ return false;
1076
1551
  }
1077
1552
  }
1078
1553
  else {
1079
1554
  this.trace('OUT', 'send.error', { channelId, error: String(e) });
1080
- logger.error(`${this.logPrefix()} Send failed to ${channelId}: ${e}`);
1555
+ logger.error(`${this.logPrefix()} Send failed to ${channelId} (outbox id=${entry.id}): ${e}`);
1556
+ return false;
1081
1557
  }
1082
1558
  }
1083
1559
  }
@@ -1094,9 +1570,8 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1094
1570
  return;
1095
1571
  if (!taskId)
1096
1572
  return;
1097
- // Multi-instance routing
1098
- const colonIdx = channelId.indexOf(':');
1099
- const targetId = colonIdx > 0 ? channelId.substring(0, colonIdx) : channelId;
1573
+ // 私聊 channelId = 对端 AID(不含 device_id)
1574
+ const targetId = channelId;
1100
1575
  const encrypt = context?.metadata?.encrypted != null
1101
1576
  ? !!(context.metadata.encrypted)
1102
1577
  : this.shouldEncrypt(targetId);
@@ -1106,7 +1581,8 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1106
1581
  encrypt,
1107
1582
  };
1108
1583
  try {
1109
- const stage = payload?.stage ?? 'unknown';
1584
+ const itemCount = Array.isArray(payload?.items) ? payload.items.length : 0;
1585
+ const stage = payload?.stage ?? `items=${itemCount}`;
1110
1586
  if (this.isGroupId(channelId)) {
1111
1587
  params.group_id = targetId;
1112
1588
  await this.callAndTrace('group.thought.put', params);
@@ -1123,11 +1599,46 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1123
1599
  logger.debug(`${this.logPrefix()} thought.put failed to ${channelId}: ${err?.name}(${err?.code})=${err?.message}`);
1124
1600
  }
1125
1601
  }
1126
- async sendFile(channelId, filePath, context) {
1127
- if (!this.connected || !this.client) {
1128
- logger.warn(`${this.logPrefix()} Cannot sendFile: not connected`);
1129
- return;
1602
+ /**
1603
+ * 发送结构化 payload(type='thought' 等)作为消息历史持久化。
1604
+ * 与 sendThought(thought.put)配对:thought.put 用于前端实时渲染(不入消息历史),
1605
+ * sendStructured 用于把同一内容写入消息历史。
1606
+ * 返回服务端分配的 message_id(失败时返回 null)。
1607
+ */
1608
+ async sendStructured(channelId, payload, context) {
1609
+ if (!this.connected || !this.client)
1610
+ return null;
1611
+ const isGroup = this.isGroupId(channelId);
1612
+ const targetAid = channelId;
1613
+ const encryptTarget = isGroup ? channelId : targetAid;
1614
+ const encrypt = context?.metadata?.encrypted != null
1615
+ ? !!(context.metadata.encrypted)
1616
+ : this.shouldEncrypt(encryptTarget);
1617
+ const finalPayload = { ...payload };
1618
+ if (context?.threadId && !finalPayload.thread_id)
1619
+ finalPayload.thread_id = context.threadId;
1620
+ const params = { payload: finalPayload, encrypt };
1621
+ try {
1622
+ if (isGroup) {
1623
+ params.group_id = channelId;
1624
+ const result = await this.callAndTrace('group.send', params);
1625
+ logger.info(`${this.logPrefix()} group.send (${payload.type}) ok: group=${channelId} mid=${result?.message_id} encrypt=${encrypt}`);
1626
+ return result?.message_id ?? null;
1627
+ }
1628
+ else {
1629
+ params.to = targetAid;
1630
+ const result = await this.callAndTrace('message.send', params);
1631
+ logger.info(`${this.logPrefix()} message.send (${payload.type}) ok: to=${this.peerLabel(targetAid)} mid=${result?.message_id} encrypt=${encrypt}`);
1632
+ return result?.message_id ?? null;
1633
+ }
1130
1634
  }
1635
+ catch (e) {
1636
+ const err = e;
1637
+ logger.warn(`${this.logPrefix()} sendStructured failed (${payload.type}) to ${channelId}: ${err?.name}(${err?.code})=${err?.message}`);
1638
+ return null;
1639
+ }
1640
+ }
1641
+ async sendFile(channelId, filePath, context) {
1131
1642
  const absPath = path.resolve(filePath);
1132
1643
  if (!fs.existsSync(absPath)) {
1133
1644
  logger.warn(`${this.logPrefix()} sendFile: file not found: ${absPath}`);
@@ -1142,15 +1653,42 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1142
1653
  logger.warn(`${this.logPrefix()} sendFile: file too large (${formatSize(stat.size)}, max 10 MB)`);
1143
1654
  return;
1144
1655
  }
1656
+ // Write-ahead: persist to outbox
1657
+ const entry = outbox.enqueue(this.config.aid, {
1658
+ channelId,
1659
+ type: 'file',
1660
+ filePath: absPath,
1661
+ context,
1662
+ });
1663
+ logger.debug(`${this.logPrefix()} Outbox enqueued file: id=${entry.id} channel=${channelId} file=${absPath}`);
1664
+ if (!this.connected || !this.client) {
1665
+ logger.warn(`${this.logPrefix()} Not connected, file send queued in outbox (id=${entry.id}). Triggering reconnect.`);
1666
+ if (!this.reconnectTimer && !this.client) {
1667
+ this.initClient().catch(e => logger.error(`${this.logPrefix()} Reconnect from sendFile failed: ${e}`));
1668
+ }
1669
+ return;
1670
+ }
1671
+ const ok = await this.deliverFileEntry(entry);
1672
+ if (ok) {
1673
+ outbox.remove(this.config.aid, entry.id);
1674
+ }
1675
+ }
1676
+ async deliverFileEntry(entry) {
1677
+ const channelId = entry.channelId;
1678
+ const absPath = entry.filePath;
1679
+ const context = entry.context;
1680
+ if (!fs.existsSync(absPath)) {
1681
+ logger.warn(`${this.logPrefix()} deliverFileEntry: file gone: ${absPath}`);
1682
+ return true; // remove from outbox, file no longer exists
1683
+ }
1145
1684
  const filename = path.basename(absPath);
1146
1685
  const fileData = fs.readFileSync(absPath);
1686
+ const stat = fs.statSync(absPath);
1147
1687
  const sha256 = crypto.createHash('sha256').update(fileData).digest('hex');
1148
1688
  const contentType = guessMime(filename);
1149
1689
  const objectKey = `shared/${crypto.randomUUID()}/${filename}`;
1150
1690
  try {
1151
- // Upload to storage
1152
1691
  if (stat.size <= 64 * 1024) {
1153
- // Inline upload for small files (≤64KB)
1154
1692
  await this.callAndTrace('storage.put_object', {
1155
1693
  object_key: objectKey,
1156
1694
  content: fileData.toString('base64'),
@@ -1160,7 +1698,6 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1160
1698
  });
1161
1699
  }
1162
1700
  else {
1163
- // Ticket upload for large files
1164
1701
  const session = await this.callAndTrace('storage.create_upload_session', {
1165
1702
  object_key: objectKey,
1166
1703
  size_bytes: stat.size,
@@ -1182,7 +1719,6 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1182
1719
  size_bytes: stat.size,
1183
1720
  });
1184
1721
  }
1185
- // Send message with attachment
1186
1722
  const attachment = {
1187
1723
  owner_aid: this._aid || '',
1188
1724
  object_key: objectKey,
@@ -1203,12 +1739,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1203
1739
  if (context?.metadata?.chatmode)
1204
1740
  filePayload.chatmode = context.metadata.chatmode;
1205
1741
  const isGroup = this.isGroupId(channelId);
1206
- // Multi-instance routing
1207
- const fileColonIdx = channelId.indexOf(':');
1208
- const fileTargetAid = fileColonIdx > 0 ? channelId.substring(0, fileColonIdx) : channelId;
1209
- if (fileColonIdx > 0) {
1210
- filePayload.chat_id = channelId;
1211
- }
1742
+ const fileTargetAid = channelId;
1212
1743
  const encryptTarget = isGroup ? channelId : fileTargetAid;
1213
1744
  const encrypt = context?.metadata?.encrypted != null
1214
1745
  ? !!(context.metadata.encrypted)
@@ -1265,10 +1796,48 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1265
1796
  }
1266
1797
  }
1267
1798
  logger.info(`${this.logPrefix()} File sent: ${filename} (${formatSize(stat.size)}) → ${channelId}`);
1799
+ return true;
1268
1800
  }
1269
1801
  catch (e) {
1270
- this.trace('OUT', 'sendFile.error', { channelId, filePath, error: String(e) });
1271
- logger.error(`${this.logPrefix()} sendFile failed for ${channelId}: ${e}`);
1802
+ this.trace('OUT', 'sendFile.error', { channelId, filePath: absPath, error: String(e) });
1803
+ logger.error(`${this.logPrefix()} sendFile failed for ${channelId} (outbox id=${entry.id}): ${e}`);
1804
+ return false;
1805
+ }
1806
+ }
1807
+ // ── Outbox drain ───────────────────────────────────────────
1808
+ outboxTimer = null;
1809
+ startOutboxTimer() {
1810
+ if (this.outboxTimer)
1811
+ return;
1812
+ this.outboxTimer = setInterval(() => {
1813
+ if (this.connected && this.client && outbox.hasPending(this.config.aid)) {
1814
+ this.drainOutbox();
1815
+ }
1816
+ }, 30_000);
1817
+ }
1818
+ stopOutboxTimer() {
1819
+ if (this.outboxTimer) {
1820
+ clearInterval(this.outboxTimer);
1821
+ this.outboxTimer = null;
1822
+ }
1823
+ }
1824
+ async drainOutbox() {
1825
+ if (!this.connected || !this.client)
1826
+ return;
1827
+ if (!outbox.hasPending(this.config.aid))
1828
+ return;
1829
+ logger.info(`${this.logPrefix()} Draining outbox...`);
1830
+ const result = await outbox.drain(this.config.aid, async (entry) => {
1831
+ if (entry.type === 'text') {
1832
+ return this.deliverTextEntry(entry);
1833
+ }
1834
+ else if (entry.type === 'file') {
1835
+ return this.deliverFileEntry(entry);
1836
+ }
1837
+ return true; // unknown type, discard
1838
+ });
1839
+ if (result.sent > 0 || result.expired > 0) {
1840
+ logger.info(`${this.logPrefix()} Outbox drained: sent=${result.sent} expired=${result.expired} failed=${result.failed}`);
1272
1841
  }
1273
1842
  }
1274
1843
  acknowledge(messageId) {
@@ -1281,6 +1850,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1281
1850
  this.sentCount.delete(channelId); // 新任务开始,重置计数
1282
1851
  if (!this.client || !this.connected)
1283
1852
  return;
1853
+ // 旧路 payload(type='event', event='task.*')—— 前后兼容保留,未来废弃
1284
1854
  const eventMap = {
1285
1855
  start: 'task.started',
1286
1856
  done: 'task.completed',
@@ -1288,55 +1858,81 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1288
1858
  error: 'task.error',
1289
1859
  timeout: 'task.timeout',
1290
1860
  };
1291
- const payload = {
1861
+ const severity = status === 'error' || status === 'timeout' ? 'error' : 'info';
1862
+ const eventPayload = {
1292
1863
  type: 'event',
1293
1864
  event: eventMap[status] ?? `task.${status}`,
1294
1865
  data: { task_id: taskId, session_id: sessionId },
1295
- severity: status === 'error' || status === 'timeout' ? 'error' : 'info',
1866
+ severity,
1296
1867
  };
1297
1868
  if (context?.threadId)
1298
- payload.thread_id = context.threadId;
1869
+ eventPayload.thread_id = context.threadId;
1870
+ // 新路 payload(type='status')—— 结构化任务状态,下游直接读字段不用解析 event 字符串
1871
+ const stateMap = {
1872
+ start: 'started',
1873
+ done: 'completed',
1874
+ interrupted: 'interrupted',
1875
+ error: 'error',
1876
+ timeout: 'timeout',
1877
+ };
1878
+ const statusPayload = {
1879
+ type: 'status',
1880
+ state: stateMap[status] ?? status,
1881
+ task_id: taskId,
1882
+ session_id: sessionId,
1883
+ severity,
1884
+ };
1885
+ if (context?.threadId)
1886
+ statusPayload.thread_id = context.threadId;
1299
1887
  const isGroup = this.isGroupId(channelId);
1300
- // Multi-instance routing
1301
- const statusColonIdx = channelId.indexOf(':');
1302
- const statusTargetAid = statusColonIdx > 0 ? channelId.substring(0, statusColonIdx) : channelId;
1303
- if (statusColonIdx > 0) {
1304
- payload.chat_id = channelId;
1305
- }
1888
+ // 私聊 channelId = 对端 AID(不含 device_id)
1889
+ const statusTargetAid = channelId;
1306
1890
  const encryptTarget = isGroup ? channelId : statusTargetAid;
1307
- const encrypt = context?.metadata?.encrypted != null
1891
+ // 计算 encrypt 标志(每次调用读最新 peerE2ee 状态,
1892
+ // 这样第二条 send 能受益于第一条触发的 peerE2ee 标记)
1893
+ const computeEncrypt = () => context?.metadata?.encrypted != null
1308
1894
  ? !!(context.metadata.encrypted)
1309
1895
  : this.shouldEncrypt(encryptTarget);
1310
- const params = { payload, encrypt };
1311
- const sendWithFallback = (method) => {
1312
- this.client.call(method, params).catch(e => {
1896
+ const sendOne = (method, payload, label) => {
1897
+ const c = this.client;
1898
+ if (!c) {
1899
+ logger.debug(`${this.logPrefix()} ${label} skipped: client gone`);
1900
+ return Promise.resolve();
1901
+ }
1902
+ const encrypt = computeEncrypt();
1903
+ const params = { payload, encrypt };
1904
+ if (isGroup)
1905
+ params.group_id = channelId;
1906
+ else
1907
+ params.to = statusTargetAid;
1908
+ this.trace('OUT', `${method}.task_${label}`, params);
1909
+ return c.call(method, params).catch(e => {
1313
1910
  if (encrypt && e instanceof E2EEError) {
1314
1911
  this.peerE2ee.set(encryptTarget, { ok: false, ts: Date.now() });
1315
- logger.warn(`${this.logPrefix()} E2EE status send failed to ${channelId}, retrying plaintext`);
1316
- params.encrypt = false;
1317
- this.client.call(method, params).catch(e2 => {
1318
- logger.debug(`${this.logPrefix()} Processing status fallback failed: ${e2}`);
1912
+ logger.warn(`${this.logPrefix()} E2EE task_${label} send failed to ${channelId}, retrying plaintext`);
1913
+ const c2 = this.client;
1914
+ if (!c2)
1915
+ return;
1916
+ const fallbackParams = { ...params, encrypt: false };
1917
+ return c2.call(method, fallbackParams).catch(e2 => {
1918
+ logger.debug(`${this.logPrefix()} task_${label} fallback failed: ${e2}`);
1319
1919
  });
1320
1920
  }
1321
- else {
1322
- logger.debug(`${this.logPrefix()} Processing status failed: ${e}`);
1323
- }
1921
+ logger.debug(`${this.logPrefix()} task_${label} failed: ${e}`);
1324
1922
  });
1325
1923
  };
1326
- if (isGroup) {
1327
- params.group_id = channelId;
1328
- this.trace('OUT', 'group.send.status', params);
1329
- sendWithFallback('group.send');
1330
- }
1331
- else {
1332
- params.to = statusTargetAid;
1333
- this.trace('OUT', 'message.send.status', params);
1334
- sendWithFallback('message.send');
1335
- }
1924
+ const method = isGroup ? 'group.send' : 'message.send';
1925
+ // 串行:等第一条完成(或失败更新 peerE2ee 标记)后再发第二条,
1926
+ // 保证两路 payload 到达顺序(event 在前,status 在后)+ 第二条能用最新 encrypt 状态
1927
+ sendOne(method, eventPayload, 'event')
1928
+ .then(() => sendOne(method, statusPayload, 'status'));
1929
+ // 统计为系统消息(按两条独立消息分别记账)
1930
+ this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, JSON.stringify(eventPayload).length, undefined, true);
1931
+ this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, JSON.stringify(statusPayload).length, undefined, true);
1336
1932
  // 群聊显示 group id 简称,P2P 显示 peer label;从 context.metadata 读取 chatmode
1337
1933
  const targetLabel = this.isGroupId(channelId) ? channelId : this.peerLabel(channelId);
1338
1934
  const chatmode = context?.metadata?.chatmode ?? '?';
1339
- logger.info(`${this.logPrefix()} task.${status} task=${taskId} session=${sessionId} chatmode=${chatmode} encrypt=${encrypt} target=${targetLabel}`);
1935
+ logger.info(`${this.logPrefix()} task.${status} task=${taskId} session=${sessionId} chatmode=${chatmode} target=${targetLabel}`);
1340
1936
  }
1341
1937
  sendCustomPayload(channelId, payload) {
1342
1938
  if (!this.client || !this.connected)
@@ -1351,12 +1947,8 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1351
1947
  catch {
1352
1948
  payloadObj = { text: payload };
1353
1949
  }
1354
- // Multi-instance routing
1355
- const customColonIdx = channelId.indexOf(':');
1356
- const customTargetAid = customColonIdx > 0 ? channelId.substring(0, customColonIdx) : channelId;
1357
- if (customColonIdx > 0) {
1358
- payloadObj.chat_id = channelId;
1359
- }
1950
+ // 私聊 channelId = 对端 AID(不含 device_id)
1951
+ const customTargetAid = channelId;
1360
1952
  const sendParams = {
1361
1953
  to: customTargetAid, payload: payloadObj,
1362
1954
  encrypt: true,
@@ -1387,43 +1979,20 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1387
1979
  this.client = null;
1388
1980
  }
1389
1981
  this.connected = false;
1390
- logger.info(`${this.logPrefix()} Disconnected`);
1391
- if (this.traceStream) {
1392
- this.traceStream.end();
1393
- this.traceStream = null;
1982
+ appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'disconnected', aid: this.config.aid, reason: 'intentional' });
1983
+ this.setAidStatus('disabled');
1984
+ if (this.traceWriter) {
1985
+ this.traceWriter.close();
1986
+ this.traceWriter = null;
1394
1987
  }
1395
1988
  logger.info(`${this.logPrefix()} Disconnected`);
1396
1989
  }
1397
- // ── TS-layer reconnect (fallback when SDK auto_reconnect exhausted) ──
1990
+ // ── TS-layer reconnect ─────────────────────────────────────
1991
+ // SDK 内部已经跑无限指数退避(max_attempts=0, max_delay=300s),
1992
+ // TS 层只负责:(1) initClient 失败时安排兜底重试;(2) flap/kicked 接管路径见 takeoverReconnect。
1398
1993
  scheduleReconnect() {
1399
- if (this.intentionalDisconnect)
1400
- return;
1401
- if (this.reconnectTimer)
1402
- return;
1403
- const delays = AUNChannel.RECONNECT_DELAYS;
1404
- if (this.reconnectAttempt >= delays.length) {
1405
- logger.error(`${this.logPrefix()} All ${delays.length} reconnect attempts exhausted, giving up`);
1406
- this.onChannelDown?.();
1407
- return;
1408
- }
1409
- const delay = delays[this.reconnectAttempt];
1410
- this.reconnectAttempt++;
1411
- logger.info(`${this.logPrefix()} Scheduling reconnect #${this.reconnectAttempt}/${delays.length} in ${delay}s`);
1412
- this.reconnectTimer = setTimeout(async () => {
1413
- this.reconnectTimer = null;
1414
- try {
1415
- logger.info(`${this.logPrefix()} Reconnect #${this.reconnectAttempt} starting...`);
1416
- this.trace('OUT', 'reconnect.start', { attempt: this.reconnectAttempt });
1417
- await this.initClient();
1418
- this.trace('OUT', 'reconnect.ok', { attempt: this.reconnectAttempt });
1419
- logger.info(`${this.logPrefix()} Reconnect #${this.reconnectAttempt} succeeded`);
1420
- }
1421
- catch (err) {
1422
- this.trace('OUT', 'reconnect.error', { attempt: this.reconnectAttempt, error: String(err) });
1423
- logger.error(`${this.logPrefix()} Reconnect #${this.reconnectAttempt} failed:`, err);
1424
- this.scheduleReconnect();
1425
- }
1426
- }, delay * 1000);
1994
+ // initClient 早期失败(auth / connect 阶段)走这里:用 fallback 延迟兜底
1995
+ this.takeoverReconnect(AUNChannel.FALLBACK_DELAY_MS, 'terminal');
1427
1996
  }
1428
1997
  /** Manually trigger reconnect (e.g. from /check reconnect command) */
1429
1998
  async reconnect() {
@@ -1433,7 +2002,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1433
2002
  clearTimeout(this.reconnectTimer);
1434
2003
  this.reconnectTimer = null;
1435
2004
  }
1436
- this.reconnectAttempt = 0;
2005
+ this.flapCount = 0;
1437
2006
  try {
1438
2007
  await this.initClient();
1439
2008
  return `重连成功 (${this._aid})`;
@@ -1443,7 +2012,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1443
2012
  return `重连失败: ${err},已安排自动重试`;
1444
2013
  }
1445
2014
  }
1446
- /** Set callback for when all reconnect attempts are exhausted */
2015
+ /** Set callback for when all reconnect attempts are exhausted (deprecated: 现在无限重试,不会触发) */
1447
2016
  setOnChannelDown(callback) {
1448
2017
  this.onChannelDown = callback;
1449
2018
  }
@@ -1452,18 +2021,20 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1452
2021
  return {
1453
2022
  connected: this.connected,
1454
2023
  aid: this._aid,
1455
- reconnectAttempt: this.reconnectAttempt,
1456
- maxAttempts: AUNChannel.RECONNECT_DELAYS.length,
2024
+ flapCount: this.flapCount,
1457
2025
  plaintextRecv: this.plaintextRecv,
1458
2026
  };
1459
2027
  }
1460
- /** 读取本地 agent.md 中的 name(用于身份上下文展示) */
2028
+ /** 读取本地 agent.md 中的 name(用于身份上下文展示),若本地不存在则尝试远程拉取 */
1461
2029
  loadSelfName(aid) {
1462
2030
  try {
1463
2031
  const aidName = aid.startsWith('@') ? aid.slice(1) : aid;
1464
2032
  const agentMdPath = path.join(os.homedir(), '.aun', 'AIDs', aidName, 'agent.md');
1465
- if (!fs.existsSync(agentMdPath))
2033
+ if (!fs.existsSync(agentMdPath)) {
2034
+ // 异步拉取,不阻塞连接流程
2035
+ this.fetchAndCacheSelfName(aidName);
1466
2036
  return undefined;
2037
+ }
1467
2038
  const content = fs.readFileSync(agentMdPath, 'utf-8');
1468
2039
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
1469
2040
  if (!fmMatch)
@@ -1475,6 +2046,27 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1475
2046
  return undefined;
1476
2047
  }
1477
2048
  }
2049
+ async fetchAndCacheSelfName(aidName) {
2050
+ try {
2051
+ const { agentmdGet } = await import('../aun/aid/index.js');
2052
+ const content = await agentmdGet(aidName);
2053
+ if (content) {
2054
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
2055
+ if (fmMatch) {
2056
+ const nameMatch = fmMatch[1].match(/^name:\s*["']?(.+?)["']?\s*$/m);
2057
+ const name = nameMatch?.[1]?.trim();
2058
+ if (name) {
2059
+ this._selfName = name;
2060
+ if (this.aidStatsCollector)
2061
+ this.aidStatsCollector.setSelfName(this.config.aid, name);
2062
+ }
2063
+ }
2064
+ }
2065
+ }
2066
+ catch {
2067
+ // ignore — name will remain undefined
2068
+ }
2069
+ }
1478
2070
  getSelfName() {
1479
2071
  return this._selfName;
1480
2072
  }
@@ -1500,6 +2092,16 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1500
2092
  return { type: null }; // no agent.md → unknown
1501
2093
  }
1502
2094
  }
2095
+ /** 同步取 peerInfo 缓存,未命中返回 undefined,不发起任何网络请求。 */
2096
+ peerInfoCached(aid) {
2097
+ return this.peerInfoCache.get(aid);
2098
+ }
2099
+ /** 后台预取 peerInfo(下次需要时缓存已就绪),任何错误吞掉。 */
2100
+ prefetchPeerInfo(aid) {
2101
+ if (this.peerInfoCache.has(aid))
2102
+ return;
2103
+ void this.fetchPeerInfo(aid).catch(() => { });
2104
+ }
1503
2105
  async uploadAgentMd(content) {
1504
2106
  if (!this.client)
1505
2107
  throw new Error('not connected');
@@ -1537,20 +2139,134 @@ export class AUNChannelPlugin {
1537
2139
  flushDelay: inst.flushDelay,
1538
2140
  encryptionSeed: inst.encryptionSeed,
1539
2141
  owner: inst.owner,
2142
+ agentName: inst.agentName,
2143
+ channelName: inst.name,
1540
2144
  aunTrace: config.debug?.aunTrace,
1541
2145
  aunSdkLog: config.debug?.aunSdkLog,
1542
2146
  });
1543
2147
  const adapter = {
1544
2148
  channelName: inst.name,
1545
- sendText: (id, text, context) => channel.sendMessage(id, text, context),
1546
- sendFile: (id, filePath, context) => channel.sendFile(id, filePath, context),
1547
- acknowledge: (messageId) => { channel.acknowledge(messageId); return Promise.resolve(); },
1548
- sendProcessingStatus: (id, status, sessionId, taskId, context) => channel.sendProcessingStatus(id, status, sessionId, taskId, context),
1549
- sendCustomPayload: (id, payload) => channel.sendCustomPayload(id, payload),
1550
- uploadAgentMd: (content) => channel.uploadAgentMd(content),
1551
- downloadAgentMd: (aid) => channel.downloadAgentMd(aid),
1552
- putThought: (id, taskId, payload, context) => channel.sendThought(id, taskId, payload, context),
1553
- _selfAid: () => channel.getStatus().aid,
2149
+ capabilities: { file: true, image: true, interaction: true, markdown: true, thought: true, status: true },
2150
+ send: async (envelope, payload) => {
2151
+ const ctx = envelope.replyContext;
2152
+ const channelId = envelope.channelId;
2153
+ switch (payload.kind) {
2154
+ case 'result.text':
2155
+ case 'command.result':
2156
+ case 'command.error':
2157
+ case 'system.notice':
2158
+ case 'system.error':
2159
+ case 'result.error': {
2160
+ const sendCtx = { ...(ctx ?? {}) };
2161
+ if (payload.kind === 'result.text' && payload.isFinal)
2162
+ sendCtx.title = '✓ 最终回复:';
2163
+ await channel.sendMessage(channelId, payload.text, sendCtx);
2164
+ return;
2165
+ }
2166
+ case 'result.file':
2167
+ await channel.sendFile(channelId, payload.filePath, ctx);
2168
+ return;
2169
+ case 'result.image': {
2170
+ // AUN 支持 image,走 sendStructured 发 type=image payload
2171
+ const buf = payload.data;
2172
+ const b64 = buf.toString('base64');
2173
+ await channel.sendStructured(channelId, {
2174
+ type: 'image',
2175
+ alt: payload.alt,
2176
+ data_base64: b64,
2177
+ mime_type: payload.mimeType,
2178
+ }, ctx);
2179
+ return;
2180
+ }
2181
+ case 'activity.batch': {
2182
+ const aunPayload = {
2183
+ type: 'thought',
2184
+ items: payload.items,
2185
+ client_context: { task_id: envelope.taskId, chatmode: envelope.chatmode, agent_name: envelope.agentName },
2186
+ };
2187
+ if (ctx?.threadId)
2188
+ aunPayload.thread_id = ctx.threadId;
2189
+ // 双发:thought.put(前端实时渲染) + message.send(消息历史持久化)
2190
+ await Promise.all([
2191
+ channel.sendThought(channelId, envelope.taskId, aunPayload, ctx),
2192
+ channel.sendStructured(channelId, aunPayload, ctx),
2193
+ ]);
2194
+ return;
2195
+ }
2196
+ case 'status.started':
2197
+ channel.sendProcessingStatus(channelId, 'start', envelope.taskId, envelope.taskId, ctx);
2198
+ return;
2199
+ case 'status.completed':
2200
+ channel.sendProcessingStatus(channelId, 'done', envelope.taskId, envelope.taskId, ctx);
2201
+ return;
2202
+ case 'status.interrupted':
2203
+ channel.sendProcessingStatus(channelId, 'interrupted', envelope.taskId, envelope.taskId, ctx);
2204
+ return;
2205
+ case 'status.error':
2206
+ channel.sendProcessingStatus(channelId, 'error', envelope.taskId, envelope.taskId, ctx);
2207
+ return;
2208
+ case 'status.timeout':
2209
+ channel.sendProcessingStatus(channelId, 'timeout', envelope.taskId, envelope.taskId, ctx);
2210
+ return;
2211
+ case 'interaction': {
2212
+ const req = payload.interaction;
2213
+ if (req.kind.kind === 'action') {
2214
+ const action = req.kind;
2215
+ const aunCard = {
2216
+ type: 'action_card',
2217
+ title: action.title,
2218
+ description: action.body,
2219
+ actions: action.buttons.map(btn => ({
2220
+ label: btn.label,
2221
+ value: btn.key,
2222
+ style: btn.style ?? 'default',
2223
+ behavior: 'reply',
2224
+ })),
2225
+ };
2226
+ if (ctx?.threadId)
2227
+ aunCard.thread_id = ctx.threadId;
2228
+ const msgId = await channel.sendStructured(channelId, aunCard, ctx);
2229
+ if (msgId) {
2230
+ channel.cardMessageIdMap.set(msgId, { requestId: req.id, isCommandCard: false });
2231
+ setTimeout(() => channel.cardMessageIdMap.delete(msgId), 20 * 60 * 1000);
2232
+ }
2233
+ }
2234
+ else if (req.kind.kind === 'command-card') {
2235
+ const card = req.kind;
2236
+ const aunCard = {
2237
+ type: 'action_card',
2238
+ title: card.title,
2239
+ description: card.body,
2240
+ actions: card.buttons.map(btn => ({
2241
+ label: btn.label,
2242
+ value: btn.command,
2243
+ style: btn.style ?? 'default',
2244
+ behavior: 'reply',
2245
+ })),
2246
+ };
2247
+ if (ctx?.threadId)
2248
+ aunCard.thread_id = ctx.threadId;
2249
+ const msgId = await channel.sendStructured(channelId, aunCard, ctx);
2250
+ if (msgId) {
2251
+ channel.cardMessageIdMap.set(msgId, { requestId: req.id, isCommandCard: true });
2252
+ setTimeout(() => channel.cardMessageIdMap.delete(msgId), 20 * 60 * 1000);
2253
+ }
2254
+ }
2255
+ else if (payload.fallbackText) {
2256
+ await channel.sendMessage(channelId, payload.fallbackText, ctx);
2257
+ }
2258
+ return;
2259
+ }
2260
+ case 'custom': {
2261
+ const text = typeof payload.payload === 'string' ? payload.payload : JSON.stringify(payload.payload);
2262
+ channel.sendCustomPayload(channelId, text);
2263
+ return;
2264
+ }
2265
+ default:
2266
+ logger.warn(`[AUN] Unhandled payload kind: ${payload.kind}`);
2267
+ }
2268
+ }, acknowledge: (messageId) => { channel.acknowledge(messageId); return Promise.resolve(); }, onInteraction: (cb) => { channel.interactionCallback = cb; }, uploadAgentMd: (content) => channel.uploadAgentMd(content),
2269
+ downloadAgentMd: (aid) => channel.downloadAgentMd(aid), _selfAid: () => channel.getStatus().aid,
1554
2270
  _selfName: () => channel.getSelfName(),
1555
2271
  };
1556
2272
  const policy = {
@@ -1595,6 +2311,53 @@ export class AUNChannelPlugin {
1595
2311
  connect: () => channel.connect(),
1596
2312
  disconnect: () => channel.disconnect(),
1597
2313
  onProjectPathRequest: (channelId) => Promise.resolve(config.projects?.defaultPath || process.cwd()),
2314
+ registerBridge(bridge, channelType) {
2315
+ bridge.register(adapter.channelName, (handler) => channel.onMessage(async (opts) => {
2316
+ handler({
2317
+ channel: adapter.channelName,
2318
+ channelType,
2319
+ channelId: opts.channelId,
2320
+ selfId: opts.selfId,
2321
+ groupId: opts.groupId,
2322
+ content: opts.content,
2323
+ chatType: opts.chatType || 'private',
2324
+ peerId: opts.peerId || '',
2325
+ peerName: opts.peerName,
2326
+ messageId: opts.messageId,
2327
+ mentions: opts.mentions,
2328
+ threadId: opts.threadId,
2329
+ replyContext: opts.replyContext,
2330
+ source: opts.source,
2331
+ });
2332
+ }), (channelId, text, replyContext) => channel.sendMessage(channelId, text, replyContext), adapter, channelType);
2333
+ },
2334
+ registerHooks(ctx) {
2335
+ channel.setEventBus(ctx.eventBus);
2336
+ if (channel.setOnChannelDown) {
2337
+ channel.setOnChannelDown(() => {
2338
+ ctx.eventBus.publish({
2339
+ type: 'channel:error',
2340
+ channel: 'aun',
2341
+ channelName: adapter.channelName,
2342
+ status: 'auth_error',
2343
+ message: `⚠️ AUN 渠道 ${adapter.channelName} 断连,自动重试已用尽。\n使用 /check rty aun 手动重连`,
2344
+ timestamp: Date.now(),
2345
+ });
2346
+ });
2347
+ }
2348
+ if (typeof channel.setSessionModeResolver === 'function') {
2349
+ channel.setSessionModeResolver(async (channelId) => {
2350
+ const session = await ctx.sessionManager.getActiveSession(adapter.channelName, channelId);
2351
+ return session?.sessionMode;
2352
+ });
2353
+ }
2354
+ if (typeof channel.setDispatchModeResolver === 'function') {
2355
+ channel.setDispatchModeResolver(async (channelId) => {
2356
+ const session = await ctx.sessionManager.getActiveSession(adapter.channelName, channelId);
2357
+ return session?.metadata?.dispatchMode;
2358
+ });
2359
+ }
2360
+ },
1598
2361
  });
1599
2362
  }
1600
2363
  return result;