evolclaw 2.8.3 → 3.1.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 (142) hide show
  1. package/README.md +21 -12
  2. package/bin/ec.js +29 -0
  3. package/dist/agents/baseagent-normalize.js +19 -0
  4. package/dist/agents/claude-runner.js +108 -46
  5. package/dist/agents/codex-runner.js +13 -14
  6. package/dist/agents/gemini-runner.js +15 -17
  7. package/dist/agents/kit-renderer.js +281 -0
  8. package/dist/agents/resolve.js +134 -0
  9. package/dist/aun/aid/agentmd.js +186 -0
  10. package/dist/aun/aid/client.js +134 -0
  11. package/dist/aun/aid/identity.js +159 -0
  12. package/dist/aun/aid/index.js +3 -0
  13. package/dist/aun/aid/lifecycle-log.js +33 -0
  14. package/dist/aun/aid/types.js +1 -0
  15. package/dist/aun/aid/validation.js +21 -0
  16. package/dist/aun/msg/group.js +293 -0
  17. package/dist/aun/msg/index.js +4 -0
  18. package/dist/aun/msg/p2p.js +147 -0
  19. package/dist/aun/msg/payload-type.js +27 -0
  20. package/dist/aun/msg/upload.js +98 -0
  21. package/dist/aun/outbox.js +138 -0
  22. package/dist/aun/rpc/caller.js +42 -0
  23. package/dist/aun/rpc/connection.js +34 -0
  24. package/dist/aun/rpc/index.js +2 -0
  25. package/dist/aun/storage/download.js +29 -0
  26. package/dist/aun/storage/index.js +3 -0
  27. package/dist/aun/storage/manage.js +10 -0
  28. package/dist/aun/storage/upload.js +35 -0
  29. package/dist/channels/aun.js +1340 -349
  30. package/dist/channels/dingtalk.js +59 -5
  31. package/dist/channels/feishu.js +381 -32
  32. package/dist/channels/qqbot.js +68 -12
  33. package/dist/channels/wechat.js +63 -4
  34. package/dist/channels/wecom.js +59 -5
  35. package/dist/cli/agent.js +800 -0
  36. package/dist/cli/bench.js +1219 -0
  37. package/dist/cli/index.js +4513 -0
  38. package/dist/{utils → cli}/init-channel.js +211 -621
  39. package/dist/cli/init.js +178 -0
  40. package/dist/cli/link-rules.js +245 -0
  41. package/dist/cli/net-check.js +640 -0
  42. package/dist/cli/watch-msg.js +589 -0
  43. package/dist/config-store.js +645 -0
  44. package/dist/core/{agent-loader.js → baseagent-loader.js} +6 -12
  45. package/dist/core/channel-loader.js +176 -12
  46. package/dist/core/command-handler.js +883 -848
  47. package/dist/core/evolagent-registry.js +191 -371
  48. package/dist/core/evolagent.js +202 -238
  49. package/dist/core/interaction-router.js +52 -5
  50. package/dist/core/message/im-renderer.js +486 -0
  51. package/dist/core/message/items-formatter.js +68 -0
  52. package/dist/core/message/message-bridge.js +109 -56
  53. package/dist/core/message/message-log.js +93 -0
  54. package/dist/core/message/message-processor.js +430 -212
  55. package/dist/core/message/message-queue.js +13 -6
  56. package/dist/core/permission.js +116 -11
  57. package/dist/core/session/adapters/codex-session-file-adapter.js +24 -2
  58. package/dist/core/session/session-fs-store.js +230 -0
  59. package/dist/core/session/session-manager.js +740 -777
  60. package/dist/core/session/session-mapper.js +87 -0
  61. package/dist/core/trigger/manager.js +122 -0
  62. package/dist/core/trigger/parser.js +128 -0
  63. package/dist/core/trigger/scheduler.js +224 -0
  64. package/dist/data/error-dict.json +118 -0
  65. package/dist/eck/baseagent-caps.js +18 -0
  66. package/dist/eck/detect.js +47 -0
  67. package/dist/eck/init.js +77 -0
  68. package/dist/eck/rules-loader.js +28 -0
  69. package/dist/index.js +560 -283
  70. package/dist/ipc.js +49 -0
  71. package/dist/net-check.js +640 -0
  72. package/dist/paths.js +73 -9
  73. package/dist/types.js +8 -2
  74. package/dist/utils/aid-lifecycle-log.js +33 -0
  75. package/dist/utils/atomic-write.js +89 -0
  76. package/dist/utils/channel-helpers.js +46 -0
  77. package/dist/utils/cross-platform.js +17 -26
  78. package/dist/utils/error-utils.js +10 -2
  79. package/dist/utils/instance-registry.js +434 -0
  80. package/dist/utils/log-writer.js +217 -0
  81. package/dist/utils/logger.js +34 -77
  82. package/dist/utils/media-cache.js +23 -0
  83. package/dist/utils/npm-ops.js +163 -0
  84. package/dist/utils/process-introspect.js +122 -0
  85. package/dist/utils/stats.js +192 -0
  86. package/dist/watch-msg.js +544 -0
  87. package/evolclaw-install-aun.md +127 -47
  88. package/kits/docs/GUIDE.md +20 -0
  89. package/kits/docs/INDEX.md +52 -0
  90. package/kits/docs/aun/CHEATSHEET.md +17 -0
  91. package/kits/docs/aun/SYNC_PROTOCOL.md +15 -0
  92. package/kits/docs/channels/aun.md +25 -0
  93. package/kits/docs/channels/feishu.md +27 -0
  94. package/kits/docs/eck_templates/GUIDE.template.md +22 -0
  95. package/kits/docs/eck_templates/INDEX.template.md +28 -0
  96. package/kits/docs/eck_templates/path-registry.template.md +33 -0
  97. package/kits/docs/eck_templates/runtime.template.md +19 -0
  98. package/kits/docs/evolclaw/AGENT_CMD.md +31 -0
  99. package/kits/docs/evolclaw/MSG_GROUP.md +30 -0
  100. package/kits/docs/evolclaw/MSG_PRIVATE.md +25 -0
  101. package/kits/docs/evolclaw/self-summary.md +29 -0
  102. package/kits/docs/evolclaw/tools.md +25 -0
  103. package/kits/docs/identity/AID_PROFILE_SPEC.md +27 -0
  104. package/kits/docs/identity/PATH_OPS.md +16 -0
  105. package/kits/docs/identity/ROLE_DETAIL.md +20 -0
  106. package/kits/docs/identity/identity-tools.md +26 -0
  107. package/kits/docs/path-registry.md +43 -0
  108. package/kits/eck_manifest.json +95 -0
  109. package/kits/rules/01-overview.md +120 -0
  110. package/kits/rules/02-navigation.md +75 -0
  111. package/kits/rules/03-identity.md +34 -0
  112. package/kits/rules/04-relation.md +49 -0
  113. package/kits/rules/05-venue.md +45 -0
  114. package/kits/rules/06-channel.md +43 -0
  115. package/kits/templates/system-fragments/baseagent.md +2 -0
  116. package/kits/templates/system-fragments/channel.md +10 -0
  117. package/kits/templates/system-fragments/identity.md +12 -0
  118. package/kits/templates/system-fragments/relation.md +9 -0
  119. package/kits/templates/system-fragments/runtime.md +19 -0
  120. package/kits/templates/system-fragments/venue.md +5 -0
  121. package/package.json +10 -6
  122. package/data/evolclaw.sample.json +0 -60
  123. package/dist/agents/templates.js +0 -122
  124. package/dist/channels/aun-ops.js +0 -275
  125. package/dist/cli.js +0 -2178
  126. package/dist/config.js +0 -591
  127. package/dist/core/agent-registry.js +0 -450
  128. package/dist/core/evolagent-schema.js +0 -72
  129. package/dist/core/message/stream-flusher.js +0 -238
  130. package/dist/core/message/thought-emitter.js +0 -162
  131. package/dist/core/reload-hooks.js +0 -87
  132. package/dist/prompts/templates.js +0 -122
  133. package/dist/templates/prompts.md +0 -104
  134. package/dist/templates/skills.md +0 -66
  135. package/dist/utils/channel-fingerprint.js +0 -59
  136. package/dist/utils/error-dict.js +0 -63
  137. package/dist/utils/format.js +0 -32
  138. package/dist/utils/init.js +0 -645
  139. package/dist/utils/migrate-project.js +0 -122
  140. package/dist/utils/reload-hooks.js +0 -87
  141. package/dist/utils/stats-collector.js +0 -99
  142. package/dist/utils/upgrade.js +0 -100
@@ -2,32 +2,56 @@ import path from 'path';
2
2
  import fs from 'fs';
3
3
  import crypto from 'crypto';
4
4
  import { hasCompact } from '../../agents/claude-runner.js';
5
- import { StreamFlusher } from './stream-flusher.js';
6
- import { ThoughtEmitter } from './thought-emitter.js';
5
+ import { appendMessageLog, buildOutboundEntry } from './message-log.js';
6
+ import { IMRenderer } from './im-renderer.js';
7
7
  import { StreamIdleMonitor } from './stream-idle-monitor.js';
8
8
  import { logger } from '../../utils/logger.js';
9
9
  import { getErrorMessage, classifyError, ErrorType, ERROR_PREFIX, isInfraError, prefixErrorType, isRetryableError } from '../../utils/error-utils.js';
10
10
  import { summarizeToolInput } from '../permission.js';
11
11
  import { DEFAULT_PERMISSION_MODE } from '../../types.js';
12
- import { getOwner } from '../../config.js';
13
12
  import { getPackageRoot, resolveRoot } from '../../paths.js';
14
- import { renderPromptSection } from '../../agents/templates.js';
13
+ import { renderKitSections } from '../../agents/kit-renderer.js';
14
+ import { normalizeBaseagent } from '../../agents/baseagent-normalize.js';
15
+ import { renderActionAsText, renderCommandCardAsText } from '../interaction-router.js';
16
+ /**
17
+ * 构造 OutboundEnvelope —— 出站三件套的信封部分。
18
+ *
19
+ * 用于所有走 adapter.send 的出站路径:
20
+ * - 任务流内的 IMRenderer 投影(chatmode 由会话决定)
21
+ * - 命令回显(MessageBridge.handleCommand,taskId 用合成 ID `cmd-...`)
22
+ * - 网关层系统通知(src/index.ts,taskId 用 `system-...` / `restart-...` 等便于 events.log 关联)
23
+ *
24
+ * 注意:
25
+ * - chatmode 缺省 `'interactive'`(系统通知 / 命令回显都属于同步交互);
26
+ * - timestamp 可由调用方注入(便于测试),缺省 `Date.now()`。
27
+ */
28
+ export function buildEnvelope(opts) {
29
+ return {
30
+ taskId: opts.taskId ?? `interaction-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
31
+ channel: opts.channel,
32
+ channelId: opts.channelId,
33
+ agentName: opts.agentName ?? '<unknown>',
34
+ chatmode: opts.chatmode ?? 'interactive',
35
+ replyContext: opts.replyContext,
36
+ timestamp: opts.timestamp ?? Date.now(),
37
+ };
38
+ }
15
39
  /**
16
40
  * 统一消息处理器
17
41
  * 负责处理来自不同渠道的消息,协调事件流处理
18
42
  */
19
43
  export class MessageProcessor {
20
44
  sessionManager;
21
- config;
45
+ globalSettings;
22
46
  messageCache;
23
47
  eventBus;
24
48
  commandHandler;
25
49
  channels = new Map();
26
50
  channelTypeMap = new Map(); // channelType → channelName(首个实例)
27
- currentFlusher;
51
+ currentRenderer;
28
52
  shouldSuppressActivities = false;
29
53
  agentMap;
30
- defaultAgentId;
54
+ primaryRunnerKey;
31
55
  interruptedSessions = new Map(); // sessionId → reason ('new_message' | 'stop' | ...)
32
56
  interactionRouter;
33
57
  messageQueue;
@@ -38,18 +62,18 @@ export class MessageProcessor {
38
62
  * - `channel` is used to look up the owning EvolAgent (via registry).
39
63
  * - `baseagent` (e.g. 'claude') comes from `session.agentId`.
40
64
  *
41
- * Falls back to `defaultAgentId` (a composite key, e.g. `[default]::claude`)
65
+ * Falls back to `primaryRunnerKey` (a composite key, e.g. `aid::claude`)
42
66
  * when no match is found.
43
67
  */
44
68
  getAgent(channel, baseagent) {
45
69
  if (channel && baseagent) {
46
- const evolName = this.agentRegistry?.resolveByChannel(channel)?.name || '[default]';
70
+ const evolName = this.agentRegistry?.resolveByChannel(channel)?.name || '<unknown>';
47
71
  const key = `${evolName}::${baseagent}`;
48
72
  if (this.agentMap.has(key))
49
73
  return this.agentMap.get(key);
50
74
  }
51
- if (this.agentMap.has(this.defaultAgentId))
52
- return this.agentMap.get(this.defaultAgentId);
75
+ if (this.agentMap.has(this.primaryRunnerKey))
76
+ return this.agentMap.get(this.primaryRunnerKey);
53
77
  return this.agentMap.values().next().value;
54
78
  }
55
79
  /** 获取可用 agent 列表 */
@@ -63,23 +87,23 @@ export class MessageProcessor {
63
87
  const active = await this.sessionManager.getActiveSession(channel, channelId);
64
88
  return active ? session.id !== active.id : false;
65
89
  }
66
- constructor(agentRunnerOrMap, sessionManager, config, messageCache, eventBus, commandHandler, defaultAgentId) {
90
+ constructor(agentRunnerOrMap, sessionManager, globalSettings, messageCache, eventBus, commandHandler, primaryRunnerKey) {
67
91
  this.sessionManager = sessionManager;
68
- this.config = config;
92
+ this.globalSettings = globalSettings;
69
93
  this.messageCache = messageCache;
70
94
  this.eventBus = eventBus;
71
95
  this.commandHandler = commandHandler;
72
96
  if (agentRunnerOrMap instanceof Map) {
73
97
  this.agentMap = agentRunnerOrMap;
74
- this.defaultAgentId = defaultAgentId || '[default]::claude';
98
+ this.primaryRunnerKey = primaryRunnerKey || '<unknown>::claude';
75
99
  }
76
100
  else {
77
- // Backward-compat single-runner path.
78
- this.agentMap = new Map([[`[default]::${agentRunnerOrMap.name}`, agentRunnerOrMap]]);
79
- this.defaultAgentId = `[default]::${agentRunnerOrMap.name}`;
101
+ // 测试 / 单 runner 路径:占位 agent name 用 '<unknown>'
102
+ this.agentMap = new Map([[`<unknown>::${agentRunnerOrMap.name}`, agentRunnerOrMap]]);
103
+ this.primaryRunnerKey = `<unknown>::${agentRunnerOrMap.name}`;
80
104
  }
81
105
  // 监听中断事件,标记被中断的 session
82
- this.eventBus.subscribe('message:interrupted', (event) => {
106
+ this.eventBus.subscribe('task:interrupted', (event) => {
83
107
  if ('sessionId' in event && event.sessionId) {
84
108
  this.interruptedSessions.set(event.sessionId, event.reason || 'unknown');
85
109
  }
@@ -101,7 +125,8 @@ export class MessageProcessor {
101
125
  const agent = this.agentRegistry.resolveByChannel(channelName);
102
126
  if (!agent)
103
127
  return null;
104
- const globalCm = this.config.chatmode;
128
+ // chatmode 解析优先级:agent.config.chatmode > globalSettings.chatmode
129
+ const globalCm = agent.config?.chatmode ?? this.globalSettings.chatmode;
105
130
  return agent.getContext(channelName, chatType, globalCm);
106
131
  }
107
132
  /**
@@ -132,10 +157,10 @@ export class MessageProcessor {
132
157
  */
133
158
  handleCompactStart(sessionId) {
134
159
  if (sessionId) {
135
- this.eventBus.publish({ type: 'agent:compact-start', sessionId });
160
+ this.eventBus.publish({ type: 'runner:compact-start', sessionId });
136
161
  }
137
- if (this.currentFlusher && !this.shouldSuppressActivities) {
138
- this.currentFlusher.addActivity('\u23f3 会话压缩中...');
162
+ if (this.currentRenderer && !this.shouldSuppressActivities) {
163
+ this.currentRenderer.addNotice('\u23f3 会话压缩中...', 'info', 'compact-start', true);
139
164
  }
140
165
  }
141
166
  /**
@@ -159,7 +184,7 @@ export class MessageProcessor {
159
184
  '/model', '/effort', '/agent', '/slist', '/session', '/rename', '/repair', '/fork',
160
185
  '/stop', '/clear', '/compact', '/safe', '/del', '/perm', '/file', '/check',
161
186
  '/p ', '/s ', '/name ', '/rewind', '/rw', '/rw ', '/activity', '/chatmode',
162
- '/aid', '/agentmd',
187
+ '/aid', '/agentmd', '/upgrade',
163
188
  ];
164
189
  /** 判断消息内容是否为已知命令 */
165
190
  isKnownCommand(content) {
@@ -170,7 +195,7 @@ export class MessageProcessor {
170
195
  * 处理消息(主入口)
171
196
  */
172
197
  async processMessage(message) {
173
- const idleMs = (this.config.idleMonitor?.timeout ?? 120) * 1000;
198
+ const idleMs = (this.globalSettings.idleMonitor?.timeout ?? 120) * 1000;
174
199
  // 先解析会话,再优先用 session.metadata.channelName 精确定位实例级 adapter
175
200
  // message.channel 现在存实例名(channelName),可直接用于精确路由
176
201
  const { session, absoluteProjectPath } = await this.resolveSession(message);
@@ -184,6 +209,7 @@ export class MessageProcessor {
184
209
  const streamKey = session.id;
185
210
  const chatType = message.chatType || 'private';
186
211
  const identityRole = session.identity?.role || 'anonymous';
212
+ const agentNameForMonitor = this.agentRegistry?.resolveByChannel(channelKey)?.name ?? '<unknown>';
187
213
  // Resolve agent context from registry (Phase 2 foundation)
188
214
  const agentContext = this.getAgentContext(channelKey, chatType);
189
215
  if (agentContext) {
@@ -191,7 +217,7 @@ export class MessageProcessor {
191
217
  }
192
218
  // 按 session.agentId 选择 agent 后端
193
219
  const agent = this.getAgent(channelKey, session.agentId);
194
- const monitorEnabled = this.config.idleMonitor?.enabled !== false;
220
+ const monitorEnabled = this.globalSettings.idleMonitor?.enabled !== false;
195
221
  const showIdleMonitor = policy.showIdleMonitor(chatType, identityRole);
196
222
  // 计算是否抑制中间输出(工具活动 + 流式文本)
197
223
  const shouldSuppress = () => {
@@ -217,13 +243,13 @@ export class MessageProcessor {
217
243
  while (result) {
218
244
  if (result.action === 'kill') {
219
245
  logger.warn(`[MessageProcessor] Idle monitor: kill after ${result.idleSec}s idle, stream: ${streamKey}`);
220
- this.eventBus.publish({ type: 'agent:idle-timeout', sessionId: streamKey, idleSec: result.idleSec });
246
+ this.eventBus.publish({ type: 'runner:idle-timeout', sessionId: streamKey, idleSec: result.idleSec });
221
247
  // 后台任务也需要中断(释放资源),但不发送通知
222
248
  if (channelInfo && !isBackground) {
223
249
  const msg = showIdleMonitor
224
250
  ? result.message
225
251
  : `\u26a0\ufe0f 任务超时(${result.idleSec}秒无响应),已自动中断`;
226
- channelInfo.adapter.sendText(message.channelId, msg, this.getReplyContext(message)).catch(e => {
252
+ channelInfo.adapter.send(buildEnvelope({ channel: channelInfo.adapter.channelName, channelId: message.channelId, agentName: agentNameForMonitor }), { kind: 'system.notice', text: msg, subtype: 'health' }).catch(e => {
227
253
  logger.debug(`[MessageProcessor] Failed to send kill diagnostic message:`, e);
228
254
  });
229
255
  }
@@ -239,7 +265,7 @@ export class MessageProcessor {
239
265
  logger.info(`[MessageProcessor] Idle monitor: ${result.action} after ${result.idleSec}s idle, stream: ${streamKey}`);
240
266
  if (channelInfo && showIdleMonitor && !shouldSuppress()) {
241
267
  if (!isBackground) {
242
- channelInfo.adapter.sendText(message.channelId, result.message, this.getReplyContext(message)).catch(e => {
268
+ channelInfo.adapter.send(buildEnvelope({ channel: channelInfo.adapter.channelName, channelId: message.channelId, agentName: agentNameForMonitor }), { kind: 'system.notice', text: result.message, subtype: 'health' }).catch(e => {
243
269
  logger.debug(`[MessageProcessor] Failed to send idle monitor message:`, e);
244
270
  });
245
271
  }
@@ -302,8 +328,8 @@ export class MessageProcessor {
302
328
  const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
303
329
  const channelKey = session.metadata?.channelName || message.channel;
304
330
  const channelInfo = this.resolveChannelInfo(channelKey);
305
- // Per-method agent name for stats bucketing (agent.name or '[default]')
306
- const agentNameForStats = this.agentRegistry?.resolveByChannel(channelKey)?.name ?? '[default]';
331
+ // Per-method agent name for stats bucketing (agent.name or '<unknown>')
332
+ const agentNameForStats = this.agentRegistry?.resolveByChannel(channelKey)?.name ?? '<unknown>';
307
333
  if (!channelInfo) {
308
334
  logger.error(`[MessageProcessor] Unknown channel: ${channelKey}`);
309
335
  return;
@@ -321,6 +347,8 @@ export class MessageProcessor {
321
347
  // 为本次任务处理生成唯一 task_id(客户端生成,格式 task-{10hex})
322
348
  const taskId = `task-${crypto.randomUUID().replace(/-/g, '').slice(0, 10)}`;
323
349
  const chatmode = session.sessionMode ?? 'interactive';
350
+ // 诊断日志:记录 inbound message_id 和生成的 task_id 的对应关系
351
+ logger.info(`[MessageProcessor] Task created: inboundMsgId=${message.messageId ?? 'none'} taskId=${taskId} sessionId=${session.id} chatmode=${chatmode}`);
324
352
  // 构建带 taskId/chatmode 的 ReplyContext(本次任务所有出站消息共用)
325
353
  const taskReplyContext = () => {
326
354
  const base = this.getReplyContext(message);
@@ -329,8 +357,16 @@ export class MessageProcessor {
329
357
  metadata: { ...(base?.metadata ?? {}), taskId, chatmode },
330
358
  };
331
359
  };
332
- // Proactive 模式可观测:ThoughtEmitter 声明在 try 外,catch 块也能透传错误为 thought
333
- let thoughtEmitter = null;
360
+ const isProactive = session.sessionMode === 'proactive';
361
+ const isAutonomous = session.sessionMode === 'autonomous' || message.triggerMeta?.silent === true;
362
+ const envelope = buildEnvelope({
363
+ taskId,
364
+ channel: message.channel,
365
+ channelId: message.channelId,
366
+ agentName: agentNameForStats,
367
+ chatmode: isProactive ? 'proactive' : 'interactive',
368
+ replyContext: taskReplyContext(),
369
+ });
334
370
  try {
335
371
  const isBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
336
372
  // 记录收到消息
@@ -360,8 +396,11 @@ export class MessageProcessor {
360
396
  const peerLabel = peerName && peerName !== peerShort ? `${peerShort}(${peerName})` : peerShort;
361
397
  logger.info(`[MessageProcessor] session=${session.id} task=${taskId} peer=${peerLabel} chatType=${session.chatType} sessionMode=${session.sessionMode} agentId=${session.agentId} msgChatType=${message.chatType ?? 'n/a'}`);
362
398
  // 记录开始处理
363
- this.eventBus.publish({ type: 'message:processing', sessionId: session.id });
364
- adapter.sendProcessingStatus?.(message.channelId, 'start', session.id, taskId, taskReplyContext());
399
+ this.eventBus.publish({ type: 'task:started', sessionId: session.id });
400
+ // 触发器消息不发 processing status(无需通知用户)
401
+ if (message.source !== 'trigger') {
402
+ adapter.send(envelope, { kind: 'status.started' }).catch(() => { });
403
+ }
365
404
  logger.message({
366
405
  msgId: messageId,
367
406
  sessionId: session.id,
@@ -369,42 +408,47 @@ export class MessageProcessor {
369
408
  status: 'processing'
370
409
  });
371
410
  const startTime = Date.now();
372
- // 创建 StreamFlusher,传入文件标记模式用于自动过滤
373
- // 使用动态判断,确保切换项目后不会继续输出
411
+ // 创建 IMRenderer(统一 interactive/proactive 两条路径)
374
412
  let firstReply = true;
375
- const isProactive = session.sessionMode === 'proactive';
376
- const flusher = new StreamFlusher(async (text, isFinal, hasText) => {
377
- const isCurrentlyBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
378
- if (!isCurrentlyBackground) {
413
+ const renderer = new IMRenderer({
414
+ adapter,
415
+ envelope,
416
+ flushDelay: (options?.flushDelay ?? this.agentRegistry?.resolveByChannel(channelKey)?.config?.flush_delay ?? 3) * 1000,
417
+ suppressActivities: shouldSuppress() || isAutonomous,
418
+ fileMarkerPattern: options?.fileMarkerPattern,
419
+ diagEnabled: this.globalSettings.debug?.flusherDiag,
420
+ send: async (payload) => {
421
+ if (isAutonomous)
422
+ return; // autonomous session: never send to channel
423
+ // proactive 模式:activity.batch 是 thought 协议内容,只发给支持 thought 的 channel
424
+ // (不支持 thought 的 channel 静默丢弃,避免降级为普通消息)
425
+ if (isProactive && payload.kind === 'activity.batch' && !adapter.capabilities?.thought)
426
+ return;
427
+ const isCurrentlyBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
428
+ if (isCurrentlyBackground)
429
+ return;
379
430
  const opts = {};
380
- if (isFinal)
381
- opts.title = '\u2713 \u6700\u7ec8\u56de\u590d:';
382
- // replyContext 跟着任务走:优先用当前 message 的,兜底用 session 的(话题会话创建时写入)
383
- const replyCtx = this.getReplyContext(message);
384
- if (replyCtx) {
385
- Object.assign(opts, replyCtx);
431
+ const baseReplyCtx = this.getReplyContext(message);
432
+ if (baseReplyCtx) {
433
+ Object.assign(opts, baseReplyCtx);
386
434
  }
387
435
  else if (firstReply && message.messageId) {
388
- // 主会话:首条消息引用回复用户原消息(只在含真实文字时消费)
389
- if (hasText) {
436
+ if (payload.kind === 'result.text' && payload.text) {
390
437
  opts.replyToMessageId = message.messageId;
391
438
  firstReply = false;
392
439
  }
393
440
  }
441
+ if (payload.kind === 'result.text' && payload.isFinal) {
442
+ opts.title = '\u2705 \u6700\u7ec8\u56de\u590d:';
443
+ }
394
444
  opts.metadata = { ...(opts.metadata ?? {}), taskId, chatmode };
395
- await adapter.sendText(message.channelId, text, opts);
396
- }
397
- // 后台任务:静默,不发送输出
398
- }, (options?.flushDelay ?? this.config.flushDelay ?? 3) * 1000, options?.fileMarkerPattern, this.config.debug?.flusherDiag, isProactive);
399
- // 保存当前 flusher,用于 compact 事件
400
- this.currentFlusher = flusher;
445
+ const enrichedEnvelope = { ...envelope, replyContext: opts };
446
+ await adapter.send(enrichedEnvelope, payload);
447
+ },
448
+ });
449
+ this.currentRenderer = renderer;
401
450
  if (isProactive) {
402
- logger.info(`[MessageProcessor] proactive mode: flusher silent, outputs via thought.put task=${taskId}`);
403
- }
404
- // Proactive 模式可观测:创建 ThoughtEmitter,将静默的流式事件转发为 thought
405
- // selector: context = { type: 'task', id: taskId }
406
- if (isProactive && adapter.putThought) {
407
- thoughtEmitter = new ThoughtEmitter(adapter, message.channelId, taskId, chatmode, this.getReplyContext(message));
451
+ logger.info(`[MessageProcessor] proactive mode: outputs via thought.put task=${taskId}`);
408
452
  }
409
453
  // 调用 AgentRunner(含上下文过长自动 compact 重试)
410
454
  // 捕获当前消息的上下文(闭包),避免后续消息处理时串台
@@ -412,7 +456,7 @@ export class MessageProcessor {
412
456
  const capturedReplyContext = taskReplyContext();
413
457
  // 设置权限审批的消息发送回调(指向当前渠道)
414
458
  agent.setSendPrompt(async (text) => {
415
- await adapter.sendText(capturedChannelId, text, capturedReplyContext);
459
+ await adapter.send({ ...envelope, replyContext: capturedReplyContext }, { kind: 'result.text', text, isFinal: true });
416
460
  });
417
461
  // 设置权限审批的交互上下文(支持交互卡片)
418
462
  agent.setPermissionContext?.(session.id, {
@@ -420,6 +464,11 @@ export class MessageProcessor {
420
464
  channelId: capturedChannelId,
421
465
  replyContext: capturedReplyContext,
422
466
  interactionRouter: this.interactionRouter,
467
+ userId: message.peerId || undefined,
468
+ channel: message.channel,
469
+ agentName: agentNameForStats,
470
+ taskId,
471
+ chatmode: isProactive ? 'proactive' : 'interactive',
423
472
  interceptNextMessage: this.messageQueue
424
473
  ? (sessionKey, handler) => this.messageQueue.interceptNext(sessionKey, handler)
425
474
  : undefined,
@@ -442,73 +491,77 @@ export class MessageProcessor {
442
491
  ? `【新消息插入】\n\n${message.content}\n\n【请无视之前中断继续处理】`
443
492
  : message.content;
444
493
  let streamResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
494
+ let effectiveSystemPrompt;
445
495
  try {
446
496
  // 动态构建运行时上下文提示
447
497
  const contextParts = [];
448
498
  const currentChannelType = options?.channelType || message.channel;
449
- // 1. 构建模板变量并渲染 runtime 段
450
- const peerName = message.peerName || session.metadata?.peerName;
451
- const peerType = message.peerType;
452
- const peerId = message.peerId;
499
+ // 提取 self 信息
453
500
  const adapterAny = channelInfo.adapter;
454
501
  const selfAid = typeof adapterAny._selfAid === 'function' ? adapterAny._selfAid() : undefined;
455
502
  const selfName = typeof adapterAny._selfName === 'function' ? adapterAny._selfName() : undefined;
456
- const formatIdentity = (name, id) => {
457
- if (name && id)
458
- return `${name} (${id})`;
459
- return name || id || undefined;
460
- };
461
- const selfIdentity = formatIdentity(selfName, selfAid);
462
- const peerIdentity = formatIdentity(peerName, peerId);
463
- // 文件发送能力(按 channelType 去重)
464
- let crossChannelTypes = [];
503
+ const peerName = message.peerName || session.metadata?.peerName;
504
+ // 文件发送能力
465
505
  let currentCanSend = false;
466
506
  if (!isProactive) {
467
- const fileChannelTypes = new Set();
468
- currentCanSend = !!channelInfo.adapter.sendFile;
469
- for (const [, info] of this.channels) {
470
- if (info.adapter.sendFile) {
471
- fileChannelTypes.add(info.options?.channelType || info.adapter.channelName);
472
- }
473
- }
474
- crossChannelTypes = [...fileChannelTypes].filter(t => t !== currentChannelType);
507
+ currentCanSend = !!(channelInfo.adapter.capabilities?.file);
475
508
  }
476
509
  // 通道能力
477
510
  const capParts = [];
478
511
  if (options?.supportsImages)
479
512
  capParts.push('图片输入');
480
- if (channelInfo.adapter.sendImage)
513
+ if (channelInfo.adapter.capabilities?.image)
481
514
  capParts.push('图片输出');
482
- if (channelInfo.adapter.sendFile)
515
+ if (channelInfo.adapter.capabilities?.file)
483
516
  capParts.push('文件发送');
484
- contextParts.push(renderPromptSection('runtime', {
485
- channel: currentChannelType,
486
- project: path.basename(absoluteProjectPath),
487
- sessionName: session.name || '',
488
- selfIdentity: selfIdentity || '',
489
- peerRole: session.identity?.role || 'unknown',
490
- peerIdentity: peerIdentity || '',
491
- peerType: peerType && peerType !== 'unknown' ? peerType : '',
492
- chatType: session.chatType || '',
493
- agent: session.agentId && session.agentId !== 'claude' ? session.agentId : '',
494
- readonly: session.metadata?.permissionMode === 'readonly',
495
- readonlySendHint: isProactive ? '使用 evolclaw ctl file 发送' : '使用 [SEND_FILE:] 发送',
496
- fileSendCurrent: !isProactive && currentCanSend,
497
- fileSendCross: !isProactive && crossChannelTypes.length > 0,
498
- crossPrimary: crossChannelTypes[0] || '',
499
- crossTypes: crossChannelTypes.join('/'),
500
- capability: capParts.length > 0,
501
- capabilities: capParts.join('、'),
502
- }));
503
- // 2. 群聊 @ 规则
504
- if (message.chatType === 'group' && message.peerId) {
505
- contextParts.push(renderPromptSection('group', { peerId: message.peerId }));
506
- }
507
- // 3. Proactive 模式提示词
508
- if (isProactive) {
509
- contextParts.push(renderPromptSection('proactive', {}));
510
- }
511
- const effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
517
+ // Personal layer
518
+ const owningAgent = this.agentRegistry?.resolveByChannel(channelKey);
519
+ const persona = owningAgent?.getPersona?.() || undefined;
520
+ const working = owningAgent?.getWorkingMemory?.() || undefined;
521
+ if (persona)
522
+ contextParts.push(persona);
523
+ if (working)
524
+ contextParts.push(`[当前关注]\n${working}`);
525
+ // 计算 peerKey: <channel>#<urlEncode(peerId)>
526
+ const peerIdRaw = message.peerId;
527
+ const peerKey = (currentChannelType && peerIdRaw)
528
+ ? `${currentChannelType}#${encodeURIComponent(peerIdRaw)}`
529
+ : undefined;
530
+ const normalizedBaseagent = normalizeBaseagent(agent.name);
531
+ // Kit renderer: 组装上下文
532
+ const kitCtx = {
533
+ vars: {
534
+ EVOLCLAW_HOME: resolveRoot(),
535
+ PACKAGE_ROOT: getPackageRoot(),
536
+ CURRENT_PROJECT: absoluteProjectPath,
537
+ selfAid: selfAid || undefined,
538
+ selfName: selfName || undefined,
539
+ hasPersona: !!persona,
540
+ hasWorkingMemory: !!working,
541
+ peerId: peerIdRaw || undefined,
542
+ peerKey,
543
+ peerName: peerName || undefined,
544
+ peerRole: session.identity?.role || 'unknown',
545
+ groupId: session.metadata?.groupId || undefined,
546
+ scene: session.chatType ? (session.chatType === 'group' ? 'group' : 'private') : 'coding',
547
+ chatType: session.chatType || null,
548
+ channel: currentChannelType || null,
549
+ venueUid: undefined,
550
+ project: path.basename(absoluteProjectPath),
551
+ sessionName: session.name || undefined,
552
+ sessionMode: isProactive ? 'proactive' : 'interactive',
553
+ readonly: session.metadata?.permissionMode === 'readonly',
554
+ canSendFile: !isProactive && currentCanSend,
555
+ capabilities: capParts.length > 0 ? capParts.join('、') : undefined,
556
+ baseAgent: normalizedBaseagent.canonical,
557
+ baseAgentName: normalizedBaseagent.displayName,
558
+ },
559
+ sessionId: session.id,
560
+ };
561
+ const kitContext = renderKitSections(kitCtx);
562
+ if (kitContext)
563
+ contextParts.push(kitContext);
564
+ effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
512
565
  // 可重试错误(403/429/5xx)指数退避重试,最多 3 次
513
566
  const MAX_RETRIES = 3;
514
567
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
@@ -518,7 +571,7 @@ export class MessageProcessor {
518
571
  const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, session.agentSessionId, message.images, effectiveSystemPrompt, this.sessionManager);
519
572
  agent.registerStream(streamKey, stream);
520
573
  streamRegistered = true;
521
- streamResult = await this.processEventStream(stream, session, flusher, resetTimer, shouldSuppress, thoughtEmitter);
574
+ streamResult = await this.processEventStream(stream, session, renderer, resetTimer, shouldSuppress);
522
575
  break; // 成功,跳出重试循环
523
576
  }
524
577
  catch (retryError) {
@@ -528,8 +581,8 @@ export class MessageProcessor {
528
581
  if (attempt < MAX_RETRIES && isRetryableError(retryError)) {
529
582
  const delay = Math.pow(2, attempt) * 1000; // 2s, 4s
530
583
  logger.warn(`[MessageProcessor] Retryable error (attempt ${attempt}/${MAX_RETRIES}), retrying in ${delay}ms:`, retryError);
531
- flusher.addActivity(`⚠️ API 暂时不可用,${delay / 1000}秒后重试 (${attempt}/${MAX_RETRIES})...`);
532
- await flusher.flush();
584
+ renderer.addNotice(`⚠️ API 暂时不可用,${delay / 1000}秒后重试 (${attempt}/${MAX_RETRIES})...`, 'warn', 'retry', true);
585
+ await renderer.flush();
533
586
  await new Promise(resolve => setTimeout(resolve, delay));
534
587
  continue;
535
588
  }
@@ -540,15 +593,15 @@ export class MessageProcessor {
540
593
  catch (error) {
541
594
  if (classifyError(error) === ErrorType.CONTEXT_TOO_LONG && session.agentSessionId && hasCompact(agent)) {
542
595
  // 尝试 compact 压缩会话
543
- flusher.addActivity('\u26a0\ufe0f 上下文过长,正在压缩会话...');
544
- await flusher.flush();
596
+ renderer.addNotice('\u26a0\ufe0f 上下文过长,正在压缩会话...', 'warn', 'compact-trigger', true);
597
+ await renderer.flush();
545
598
  const compacted = await agent.compact(session.id, session.agentSessionId, absoluteProjectPath);
546
599
  if (compacted) {
547
600
  // compact 成功,带 resume 重试(不重复原始消息,让 Agent 继续未完成的工作)
548
- flusher.addActivity('\u2705 压缩完成,正在重试...');
549
- const retryStream = await agent.runQuery(session.id, '上下文已自动压缩,请继续之前未完成的任务。', absoluteProjectPath, session.agentSessionId, undefined, options?.systemPromptAppend, this.sessionManager);
601
+ renderer.addNotice('\u2705 压缩完成,正在重试...', 'info', 'compact-retry', true);
602
+ const retryStream = await agent.runQuery(session.id, '上下文已自动压缩,请继续之前未完成的任务。', absoluteProjectPath, session.agentSessionId, undefined, effectiveSystemPrompt, this.sessionManager);
550
603
  agent.registerStream(streamKey, retryStream);
551
- streamResult = await this.processEventStream(retryStream, session, flusher, resetTimer, shouldSuppress, thoughtEmitter);
604
+ streamResult = await this.processEventStream(retryStream, session, renderer, resetTimer, shouldSuppress);
552
605
  }
553
606
  else {
554
607
  throw new Error('CONTEXT_COMPACT_FAILED');
@@ -558,14 +611,43 @@ export class MessageProcessor {
558
611
  throw error;
559
612
  }
560
613
  }
614
+ // prompt_too_long:SDK 以 complete 事件(非异常)返回,需在此处触发 compact
615
+ // 检测条件:terminalReason 明确为 prompt_too_long,或文本/errors 包含相关错误文本
616
+ const contextTooLongPattern = /prompt is too long|input is too long|上下文过长/i;
617
+ const errorsText = streamResult.errors?.join(' ') || '';
618
+ const isPromptTooLong = streamResult.isError && session.agentSessionId && hasCompact(agent) && (streamResult.terminalReason === 'prompt_too_long' ||
619
+ contextTooLongPattern.test(streamResult.lastReplyText) ||
620
+ contextTooLongPattern.test(errorsText) ||
621
+ contextTooLongPattern.test(streamResult.fullText));
622
+ if (isPromptTooLong) {
623
+ renderer.addNotice('⚠️ 上下文过长,正在压缩会话...', 'warn', 'compact-trigger', true);
624
+ await renderer.flush();
625
+ const compacted = await agent.compact(session.id, session.agentSessionId, absoluteProjectPath);
626
+ if (compacted) {
627
+ renderer.addNotice('✅ 压缩完成,正在重试...', 'info', 'compact-retry', true);
628
+ const retryStream = await agent.runQuery(session.id, '上下文已自动压缩,请继续之前未完成的任务。', absoluteProjectPath, session.agentSessionId, undefined, effectiveSystemPrompt, this.sessionManager);
629
+ agent.registerStream(streamKey, retryStream);
630
+ streamResult = await this.processEventStream(retryStream, session, renderer, resetTimer, shouldSuppress);
631
+ }
632
+ else {
633
+ throw new Error('CONTEXT_COMPACT_FAILED');
634
+ }
635
+ }
636
+ else if (streamResult.isError && !isPromptTooLong && (streamResult.terminalReason === 'prompt_too_long' ||
637
+ contextTooLongPattern.test(streamResult.lastReplyText) ||
638
+ contextTooLongPattern.test(errorsText) ||
639
+ contextTooLongPattern.test(streamResult.fullText))) {
640
+ // 上下文过长但无法 auto-compact(无 session ID 或 agent 不支持),显示友好提示
641
+ renderer.addNotice('⚠️ 上下文过长,请精简提问或使用 /compact 压缩上下文', 'warn', 'context-too-long', true);
642
+ }
561
643
  // 处理文件标记 - 支持 [SEND_FILE:path] 和 [SEND_FILE:channel:path]
562
644
  // 注意:始终扫描全部文本(含中间轮),因为文件标记可能出现在任意轮次
563
- // suppressed 模式下 flusher 只有最后一轮文本,需要用 streamResult.fullText(SDK 全文)兜底
645
+ // suppressed 模式下 renderer 只有最后一轮文本,需要用 streamResult.fullText(SDK 全文)兜底
564
646
  // proactive 模式:agent 主动调用 ctl file 发送文件,跳过标记处理
565
647
  if (!isProactive) {
566
648
  const FILE_MARKER_RE = /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g;
567
649
  const markerPattern = options?.fileMarkerPattern ?? FILE_MARKER_RE;
568
- const flusherText = flusher.getFinalText();
650
+ const flusherText = renderer.getFinalText();
569
651
  const fullText = flusherText.length >= (streamResult.fullText?.length || 0) ? flusherText : streamResult.fullText;
570
652
  const fileMatches = [...fullText.matchAll(markerPattern)];
571
653
  for (const match of fileMatches) {
@@ -596,22 +678,22 @@ export class MessageProcessor {
596
678
  && targetSpec !== currentChannelType;
597
679
  // 跨通道仅限 owner
598
680
  if (isCrossChannel && session.identity?.role !== 'owner') {
599
- await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, taskReplyContext());
681
+ await adapter.send(envelope, { kind: 'system.error', text: `\u274c 跨通道发送仅限管理员`, subtype: 'fatal' });
600
682
  continue;
601
683
  }
602
684
  const resolvedPath = this.resolveFilePath(filePath, absoluteProjectPath);
603
685
  if (!fs.existsSync(resolvedPath)) {
604
686
  logger.warn(`[${adapter.channelName}] File not found: ${resolvedPath}`);
605
- await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, taskReplyContext());
687
+ await adapter.send(envelope, { kind: 'system.error', text: `\u26a0\ufe0f 文件未找到: ${filePath}`, subtype: 'fatal' });
606
688
  continue;
607
689
  }
608
690
  // 找目标 adapter
609
691
  if (!targetInfo) {
610
- await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 未启用或不存在`, taskReplyContext());
692
+ await adapter.send(envelope, { kind: 'system.error', text: `\u274c 通道 ${targetLabel} 未启用或不存在`, subtype: 'channel_down' });
611
693
  continue;
612
694
  }
613
- if (!targetInfo.adapter.sendFile) {
614
- await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 不支持文件发送`, taskReplyContext());
695
+ if (!targetInfo.adapter.capabilities?.file) {
696
+ await adapter.send(envelope, { kind: 'system.error', text: `\u274c 通道 ${targetLabel} 不支持文件发送`, subtype: 'capability' });
615
697
  continue;
616
698
  }
617
699
  // 找目标 channelId
@@ -619,64 +701,56 @@ export class MessageProcessor {
619
701
  if (isCrossChannel) {
620
702
  const targetAdapterName = targetInfo.adapter.channelName;
621
703
  const targetChannelType = targetInfo.options?.channelType || targetAdapterName;
622
- const ownerPeerId = this.agentRegistry?.getOwner?.(targetAdapterName) ?? getOwner(this.config, targetAdapterName);
704
+ const ownerPeerId = this.agentRegistry?.getOwner?.(targetAdapterName);
623
705
  targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannelType, ownerPeerId) ?? '') : '';
624
706
  if (!targetChannelId) {
625
- await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, taskReplyContext());
707
+ await adapter.send(envelope, { kind: 'system.error', text: `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, subtype: 'channel_down' });
626
708
  continue;
627
709
  }
628
710
  }
629
711
  logger.info(`[${adapter.channelName}] Sending file via ${targetInfo.adapter.channelName}: ${resolvedPath}`);
630
712
  try {
631
- await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, taskReplyContext());
632
- this.eventBus.publish({ type: 'agent:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetInfo.adapter.channelName });
713
+ await targetInfo.adapter.send(buildEnvelope({ taskId, channel: targetInfo.adapter.channelName, channelId: targetChannelId, agentName: agentNameForStats, replyContext: taskReplyContext() }), { kind: 'result.file', filePath: resolvedPath });
714
+ this.eventBus.publish({ type: 'runner:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetInfo.adapter.channelName });
633
715
  if (isCrossChannel) {
634
- await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetLabel} 发送`, taskReplyContext());
716
+ await adapter.send(envelope, { kind: 'system.notice', text: `\ud83d\udcce 文件已通过 ${targetLabel} 发送`, subtype: 'health' });
635
717
  }
636
718
  }
637
719
  catch (error) {
638
720
  logger.error(`[${adapter.channelName}] Failed to send file: ${resolvedPath}`, error);
639
- await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, taskReplyContext());
721
+ await adapter.send(envelope, { kind: 'system.error', text: `\u274c 文件发送失败: ${filePath}`, subtype: 'fatal' });
640
722
  }
641
723
  }
642
724
  } // end of !isProactive
643
- // 最终回复文本添加到 flusher(统一在流结束后处理,避免多 complete 事件重复发送)
644
- // suppressed 模式:中间流式文本未推送,使用最后一轮回复(回退到全文)
645
- // 非 suppressed 且无流式文本:同上
646
- // 非 suppressed 且有流式文本:已经逐步推送过了,不重复添加
647
- // 但如果 flusher 既未发送过内容也没有 pending 内容(如 text 事件全为空),仍需兜底
725
+ // 最终回复文本:suppressed 模式或无 text 事件时需要兜底添加
648
726
  const finalReplyText = streamResult.lastReplyText || streamResult.fullText;
649
- // 识别 Claude SDK 本地预处理兜底(如 "Unknown skill: xxx"):
650
- // 特征:无流式 text + complete.result 匹配已知模式
651
- // 这类输出不是 agent 的回复意图,而是 SDK 本地拦截到的"未知斜杠命令"提示。
652
- // Proactive 模式下 flusher silent,需要兜底发出以告知用户,否则用户完全无反馈。
653
- const isSdkFallbackMessage = !!finalReplyText
654
- && !streamResult.hasReceivedText
655
- && /^Unknown skill:\s+\S+/i.test(finalReplyText.trim());
656
727
  if (finalReplyText) {
657
- if (isProactive && isSdkFallbackMessage) {
658
- // Proactive 模式 + SDK 本地兜底:直接 sendText 绕过 silent flusher
728
+ if (isProactive && !streamResult.hasReceivedText && /^Unknown skill:\s+\S+/i.test(finalReplyText.trim())) {
729
+ // Proactive 模式 + SDK 本地兜底:直接发送绕过 silent renderer
659
730
  const isCurrentlyBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
660
731
  if (!isCurrentlyBackground) {
661
- await adapter.sendText(message.channelId, finalReplyText, capturedReplyContext);
732
+ await adapter.send({ ...envelope, replyContext: capturedReplyContext }, { kind: 'result.text', text: finalReplyText, isFinal: true });
662
733
  logger.info(`[MessageProcessor] proactive SDK fallback replied task=${taskId} text="${finalReplyText.slice(0, 60)}"`);
663
734
  }
664
735
  }
665
- else if (shouldSuppress()) {
666
- flusher.addText(finalReplyText);
667
- }
668
- else if (!streamResult.hasReceivedText || (!flusher.hasSentContent() && !flusher.hasContent())) {
669
- flusher.addText(finalReplyText);
736
+ else if (shouldSuppress() || !streamResult.hasReceivedText) {
737
+ renderer.addText(finalReplyText);
670
738
  }
671
739
  }
672
- // Flush 剩余内容(文件标记已在 flush 时自动移除)
673
- await flusher.flush(true);
674
- // 清理 activeStreams(正常完成)
740
+ // 先清理流和处理中状态(保证即使 flush 卡住,session 也不会永久处于"处理中")
675
741
  agent.cleanupStream(streamKey);
676
742
  logger.info(`[MessageProcessor] agent.cleanupStream ok: session=${session.id} task=${taskId}`);
677
- // 清除处理中状态
678
743
  this.sessionManager.clearProcessing(session.id);
679
744
  logger.info(`[MessageProcessor] session ${session.id} processing cleared task=${taskId}`);
745
+ // 被用户中断(新消息打断)时跳过 flush — 新 task 已接管渠道,旧 task 的 flush 无意义且可能卡住
746
+ const preFlushInterrupt = this.interruptedSessions.get(session.id);
747
+ if (preFlushInterrupt === 'new_message' || preFlushInterrupt === 'stop' || preFlushInterrupt === 'recalled') {
748
+ logger.info(`[MessageProcessor] Skipping flush for interrupted task=${taskId} reason=${preFlushInterrupt}`);
749
+ }
750
+ else {
751
+ // Flush 剩余内容(文件标记已在 flush 时自动移除)
752
+ await renderer.flush(true);
753
+ }
680
754
  // 更新 EvolAgent.lastActivity
681
755
  if (this.agentRegistry) {
682
756
  const owningAgent = this.agentRegistry.resolveByChannel(channelKey);
@@ -690,9 +764,14 @@ export class MessageProcessor {
690
764
  const errorSummary = streamResult.errors?.join('; ') || '任务执行失败';
691
765
  const rawSubtype = streamResult.subtype || 'agent_error';
692
766
  const errorType = prefixErrorType(ERROR_PREFIX.AGENT, rawSubtype);
693
- adapter.sendProcessingStatus?.(message.channelId, 'error', session.id, taskId, taskReplyContext());
767
+ if (message.source !== 'trigger') {
768
+ adapter.send(envelope, { kind: 'status.error', metadata: { errorType: rawSubtype } }).catch(() => { });
769
+ }
770
+ if (message.triggerMeta) {
771
+ this.eventBus.publish({ type: 'trigger:failed', triggerId: message.triggerMeta.triggerId, messageId: messageId, error: errorSummary });
772
+ }
694
773
  this.eventBus.publish({
695
- type: 'message:error',
774
+ type: 'task:error',
696
775
  sessionId: session.id,
697
776
  error: errorSummary,
698
777
  errorType,
@@ -719,10 +798,30 @@ export class MessageProcessor {
719
798
  }
720
799
  else {
721
800
  // 真正的成功
722
- adapter.sendProcessingStatus?.(message.channelId, interruptReason ? 'interrupted' : 'done', session.id, taskId, taskReplyContext());
801
+ const durationMs = Date.now() - startTime;
802
+ if (message.source !== 'trigger') {
803
+ if (interruptReason) {
804
+ adapter.send(envelope, { kind: 'status.interrupted', metadata: { reason: interruptReason } }).catch(() => { });
805
+ }
806
+ else {
807
+ adapter.send(envelope, { kind: 'status.completed', metadata: { durationMs, numTurns: streamResult.numTurns, usage: streamResult.usage } }).catch(() => { });
808
+ }
809
+ }
810
+ if (message.triggerMeta) {
811
+ if (interruptReason) {
812
+ this.eventBus.publish({ type: 'trigger:skipped', triggerId: message.triggerMeta.triggerId, reason: 'interrupted' });
813
+ }
814
+ else {
815
+ this.eventBus.publish({ type: 'trigger:completed', triggerId: message.triggerMeta.triggerId, messageId: messageId, durationMs });
816
+ }
817
+ // Clean up autonomous sessions after completion to avoid accumulating orphaned sessions
818
+ if (session.sessionMode === 'autonomous') {
819
+ this.sessionManager.unbindSession(session.id).catch(() => { });
820
+ }
821
+ }
723
822
  await this.sessionManager.recordSuccess(session.id);
724
823
  this.eventBus.publish({
725
- type: 'message:completed',
824
+ type: 'task:completed',
726
825
  sessionId: session.id,
727
826
  channel: message.channel,
728
827
  channelId: message.channelId,
@@ -740,12 +839,30 @@ export class MessageProcessor {
740
839
  status: 'completed',
741
840
  duration: Date.now() - startTime
742
841
  });
842
+ // 写入消息记录(出方向)
843
+ if (streamResult.lastReplyText || streamResult.fullText) {
844
+ const chatDir = this.sessionManager.getChatDir(session);
845
+ appendMessageLog(chatDir, buildOutboundEntry({
846
+ from: message.selfId || session.selfId || 'self',
847
+ to: message.peerId || message.channelId,
848
+ chatType: (message.chatType || session.chatType || 'private'),
849
+ groupId: session.metadata?.groupId ?? null,
850
+ msgId: `${messageId}_reply`,
851
+ content: streamResult.lastReplyText || streamResult.fullText,
852
+ replyTo: message.messageId ?? null,
853
+ agent: session.agentId || null,
854
+ model: agent.getModel?.() || null,
855
+ durationMs: Date.now() - startTime,
856
+ numTurns: streamResult.numTurns,
857
+ usage: streamResult.usage,
858
+ }));
859
+ }
743
860
  }
744
861
  const isFinallyBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
745
- if (isFinallyBackground) {
862
+ if (isFinallyBackground && session.sessionMode !== 'autonomous') {
746
863
  const projectName = path.basename(session.projectPath);
747
864
  const count = this.messageCache.getCount(session.id);
748
- await adapter.sendText(message.channelId, `[\u540e\u53f0-${projectName}] \u2713 任务完成 (${count}条消息已缓存)`, taskReplyContext());
865
+ await adapter.send(envelope, { kind: 'system.notice', text: `[\u540e\u53f0-${projectName}] \u2713 任务完成 (${count}条消息已缓存)`, subtype: 'background' });
749
866
  }
750
867
  // 记录发送响应
751
868
  logger.message({
@@ -774,10 +891,12 @@ export class MessageProcessor {
774
891
  : 'error';
775
892
  // 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送中断/错误提示
776
893
  if (!isUserInterrupt) {
777
- try {
778
- adapter.sendProcessingStatus?.(message.channelId, procStatus, session.id, taskId, taskReplyContext());
779
- }
780
- catch { }
894
+ const statusPayload = procStatus === 'timeout'
895
+ ? { kind: 'status.timeout' }
896
+ : procStatus === 'interrupted'
897
+ ? { kind: 'status.interrupted', metadata: { reason: 'stream_error' } }
898
+ : { kind: 'status.error' };
899
+ adapter.send(envelope, statusPayload).catch(() => { });
781
900
  }
782
901
  // 用户主动中断时降级日志;其余仍按 error 记录
783
902
  if (isUserInterrupt) {
@@ -789,7 +908,7 @@ export class MessageProcessor {
789
908
  const errorMsg = error instanceof Error ? error.message : String(error);
790
909
  const errorType = prefixErrorType(ERROR_PREFIX.INFRA, errType);
791
910
  this.eventBus.publish({
792
- type: 'message:error',
911
+ type: 'task:error',
793
912
  sessionId: session.id,
794
913
  error: errorMsg,
795
914
  errorType,
@@ -808,7 +927,7 @@ export class MessageProcessor {
808
927
  }
809
928
  // 发送用户友好的错误消息(SDK_TIMEOUT 已在 kill 级别发过提示,跳过)
810
929
  // 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送错误提示
811
- // processEventStream 已通过 flusher 发过错误时也跳过
930
+ // processEventStream 已通过 renderer 发过错误时也跳过
812
931
  if (error instanceof Error && error.message === 'SDK_TIMEOUT') {
813
932
  logger.info(`[MessageProcessor] SDK_TIMEOUT error, skip sending duplicate message`);
814
933
  }
@@ -816,14 +935,14 @@ export class MessageProcessor {
816
935
  logger.info(`[MessageProcessor] User interrupt by new_message, skip sending error message`);
817
936
  }
818
937
  else if (error?._errorAlreadySent) {
819
- logger.info(`[MessageProcessor] Error already sent via flusher, skip sending duplicate message`);
938
+ logger.info(`[MessageProcessor] Error already sent via renderer, skip sending duplicate message`);
820
939
  }
821
940
  else {
822
941
  const userMessage = getErrorMessage(error, undefined);
823
942
  // 获取 session 用于话题回复(如果 resolveSession 已执行)
824
943
  let sendOpts;
825
944
  try {
826
- await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId);
945
+ await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.agentRegistry?.resolveByChannel(message.channel)?.projectPath || process.cwd(), message.threadId);
827
946
  sendOpts = this.getReplyContext(message);
828
947
  }
829
948
  catch { }
@@ -832,15 +951,8 @@ export class MessageProcessor {
832
951
  ...(sendOpts ?? {}),
833
952
  metadata: { ...(sendOpts?.metadata ?? {}), taskId, chatmode },
834
953
  };
835
- await adapter.sendText(message.channelId, userMessage, sendOpts);
954
+ await adapter.send({ ...envelope, replyContext: sendOpts }, { kind: 'result.text', text: userMessage, isFinal: true });
836
955
  // Proactive 可观测:catch 块的基础设施错误也透传为 thought,保证按 task_id 聚合完整
837
- if (thoughtEmitter) {
838
- const thoughtErrorType = errType === ErrorType.CONTEXT_TOO_LONG ? 'context_too_long' :
839
- errType === ErrorType.AUTH_ERROR ? 'auth' :
840
- (errType === ErrorType.SDK_TIMEOUT || errType === ErrorType.STREAM_ERROR) ? 'network' :
841
- 'unknown';
842
- thoughtEmitter.emit({ type: 'error', error: userMessage, errorType: thoughtErrorType }).catch(() => { });
843
- }
844
956
  }
845
957
  }
846
958
  }
@@ -852,7 +964,29 @@ export class MessageProcessor {
852
964
  const metadata = (message.threadId && message.replyContext)
853
965
  ? { replyContext: message.replyContext }
854
966
  : undefined;
855
- const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId, metadata, undefined, message.peerId);
967
+ const projectPath = this.agentRegistry?.resolveByChannel(message.channel)?.projectPath || process.cwd();
968
+ // --session silent 触发器:新建独立 autonomous 会话,与原会话历史隔离
969
+ if (message.triggerMeta?.silent) {
970
+ const prevActive = await this.sessionManager.getActiveSession(message.channel, message.channelId);
971
+ const session = await this.sessionManager.createNewSession(message.channel, message.channelId, projectPath, `trigger-${message.triggerMeta.triggerId.slice(0, 8)}`);
972
+ await this.sessionManager.updateSession(session.id, { sessionMode: 'autonomous' });
973
+ session.sessionMode = 'autonomous';
974
+ if (prevActive) {
975
+ await this.sessionManager.switchToSession(message.channel, message.channelId, prevActive.id);
976
+ }
977
+ const absoluteProjectPath = path.isAbsolute(session.projectPath)
978
+ ? session.projectPath
979
+ : path.resolve(process.cwd(), session.projectPath);
980
+ return { session, absoluteProjectPath };
981
+ }
982
+ const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, projectPath, message.threadId, metadata, undefined, message.peerId, undefined, undefined, undefined, undefined, message.peerType);
983
+ // 兜底纠正:旧 session 创建时没传 peerType(建为 interactive),后续非 human 消息进来时升级为 proactive。
984
+ // 新建场景已由 getOrCreateSession 内部 resolveDefaultSessionMode 处理,这里只兜底历史会话。
985
+ if (message.peerType && message.peerType !== 'human' && message.peerType !== 'unknown' && session.sessionMode !== 'proactive') {
986
+ logger.info(`[MessageProcessor] proactive upgrade: sessionId=${session.id} ${session.sessionMode} -> proactive (peerType=${message.peerType})`);
987
+ session.sessionMode = 'proactive';
988
+ await this.sessionManager.updateSession(session.id, { sessionMode: 'proactive' });
989
+ }
856
990
  // replyContext 不再写入 session.metadata(跟着 message 走,避免群聊多人覆盖)
857
991
  const absoluteProjectPath = path.isAbsolute(session.projectPath)
858
992
  ? session.projectPath
@@ -865,14 +999,16 @@ export class MessageProcessor {
865
999
  * 此方法只消费标准 AgentEvent 类型,不引用任何 SDK 特有事件。
866
1000
  * SDK 事件 → AgentEvent 的转换在 AgentRunner.transformStream() 中完成。
867
1001
  */
868
- async processEventStream(stream, session, flusher, resetTimer, shouldSuppress, thoughtEmitter) {
1002
+ async processEventStream(stream, session, renderer, resetTimer, shouldSuppress) {
869
1003
  // Per-session agent name for stats bucketing
870
- const agentNameForStats = this.agentRegistry?.resolveByChannel(session.metadata?.channelName || session.channel)?.name ?? '[default]';
1004
+ const agentNameForStats = this.agentRegistry?.resolveByChannel(session.metadata?.channelName || session.channel)?.name ?? '<unknown>';
871
1005
  let hasReceivedText = false;
872
1006
  let hasErrorResult = false; // 是否已有 tool_result/error 事件输出过错误
873
1007
  let completeResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
874
1008
  // 追踪最后一轮 assistant 回复文本(tool_use 之后的纯文本)
875
1009
  let lastReplyText = '';
1010
+ // callId → description 映射,用于 tool_result 回显描述
1011
+ const toolDescByCallId = new Map();
876
1012
  try {
877
1013
  for await (const event of stream) {
878
1014
  // 每收到事件重置空闲超时
@@ -906,10 +1042,8 @@ export class MessageProcessor {
906
1042
  else {
907
1043
  logger.info(`[MessageProcessor] Event: type=${event.type}${eventDetail}`);
908
1044
  }
909
- // Proactive 可观测:将事件实时透传为 thought(fire-and-forget)
910
- if (thoughtEmitter) {
911
- thoughtEmitter.emit(event).catch(() => { });
912
- }
1045
+ // IMRenderer 旁路:proactive 模式逐事件投影为 thought(fire-and-forget)
1046
+ renderer.emit(event);
913
1047
  // session_id 已在 AgentRunner.transformStream 中处理,此处仅记录
914
1048
  if (event.type === 'session_id') {
915
1049
  logger.debug(`[MessageProcessor] Session ID updated: ${event.sessionId} for session: ${session.id}`);
@@ -918,14 +1052,14 @@ export class MessageProcessor {
918
1052
  // session 状态变更(idle/running/requires_action)
919
1053
  if (event.type === 'state_changed') {
920
1054
  logger.debug(`[MessageProcessor] Session state: ${event.state} for session: ${session.id}`);
921
- this.eventBus.publish({ type: 'agent:state-changed', sessionId: session.id, state: event.state });
1055
+ this.eventBus.publish({ type: 'runner:state-changed', sessionId: session.id, state: event.state });
922
1056
  continue;
923
1057
  }
924
1058
  // agent 状态通知(仅事件,不直出给用户)
925
1059
  if (event.type === 'status') {
926
1060
  logger.debug(`[MessageProcessor] Agent status: ${event.subtype}: ${event.message}`);
927
1061
  this.eventBus.publish({
928
- type: 'agent:status',
1062
+ type: 'runner:status',
929
1063
  sessionId: session.id,
930
1064
  subtype: event.subtype,
931
1065
  message: event.message,
@@ -942,14 +1076,14 @@ export class MessageProcessor {
942
1076
  lastReplyText += event.text;
943
1077
  this.eventBus.publish({ type: 'message:text', sessionId: session.id, text: event.text, isFinal: false });
944
1078
  if (!shouldSuppress()) {
945
- flusher.addText(event.text);
1079
+ renderer.addText(event.text);
946
1080
  }
947
1081
  }
948
1082
  // compact 完成
949
1083
  if (event.type === 'compact') {
950
- this.eventBus.publish({ type: 'agent:compact-complete', sessionId: session.id, preTokens: event.preTokens });
1084
+ this.eventBus.publish({ type: 'runner:compact-complete', sessionId: session.id, preTokens: event.preTokens });
951
1085
  if (!shouldSuppress()) {
952
- flusher.addActivity(`\ud83d\udca1 会话压缩完成,继续执行...(压缩前 tokens: ${event.preTokens})`);
1086
+ renderer.addNotice(`\ud83d\udca1 会话压缩完成,继续执行...(压缩前 tokens: ${event.preTokens})`, 'info', 'compact');
953
1087
  }
954
1088
  }
955
1089
  // 子任务进度
@@ -958,15 +1092,19 @@ export class MessageProcessor {
958
1092
  const duration = event.durationMs ? `${Math.round(event.durationMs / 1000)}s` : '';
959
1093
  const stats = [tools > 0 ? `${tools}\u6b21\u5de5\u5177\u8c03\u7528` : '', duration].filter(Boolean).join(', ');
960
1094
  if (event.summary && !shouldSuppress()) {
961
- flusher.addActivity(`\u23f3 \u5b50\u4efb\u52a1: ${event.summary}${stats ? ` (${stats})` : ''}`);
1095
+ renderer.addProgress(`\u5b50\u4efb\u52a1: ${event.summary}${stats ? ` (${stats})` : ''}`, { state: 'processing', toolUses: event.toolUses, durationMs: event.durationMs });
962
1096
  }
963
1097
  else if (stats && !shouldSuppress()) {
964
- flusher.addActivity(`\u23f3 \u5b50\u4efb\u52a1\u8fdb\u884c\u4e2d: ${stats}`);
1098
+ renderer.addProgress(`\u5b50\u4efb\u52a1\u8fdb\u884c\u4e2d: ${stats}`, { state: 'processing', toolUses: event.toolUses, durationMs: event.durationMs });
965
1099
  }
966
1100
  }
967
1101
  // 工具调用
968
1102
  if (event.type === 'tool_use') {
969
- // 工具调用意味着当前文本是中间轮,重置最后回复追踪
1103
+ // 工具调用意味着当前 turn 结束,flush 已累积的文本作为独立消息
1104
+ if (renderer.hasTextPending()) {
1105
+ await renderer.flushText();
1106
+ }
1107
+ // 重置最后回复追踪
970
1108
  lastReplyText = '';
971
1109
  this.eventBus.publish({
972
1110
  type: 'tool:use',
@@ -977,7 +1115,10 @@ export class MessageProcessor {
977
1115
  });
978
1116
  if (!shouldSuppress()) {
979
1117
  const desc = summarizeToolInput(event.name, event.input || {});
980
- flusher.addActivity(`\ud83d\udd27 ${event.name}${desc ? ': ' + desc : ''}`);
1118
+ if (event.callId) {
1119
+ toolDescByCallId.set(event.callId, desc);
1120
+ }
1121
+ renderer.addToolCall(event.name, event.input, event.callId, desc);
981
1122
  }
982
1123
  }
983
1124
  // 工具结果
@@ -991,50 +1132,63 @@ export class MessageProcessor {
991
1132
  agentName: agentNameForStats,
992
1133
  timestamp: Date.now()
993
1134
  });
1135
+ // 从 tool_use 阶段缓存的描述中回溯
1136
+ const cachedDesc = event.callId ? toolDescByCallId.get(event.callId) : undefined;
994
1137
  if (event.isError && !shouldSuppress()) {
995
1138
  hasErrorResult = true;
996
1139
  let errorMsg = event.error || (typeof event.result === 'string' ? event.result : JSON.stringify(event.result)) || '\u6267\u884c\u5931\u8d25';
997
1140
  // 移除 XML 风格的错误标签
998
1141
  errorMsg = errorMsg.replace(/<tool_use_error>(.*?)<\/tool_use_error>/gs, '$1');
999
- flusher.addActivity(`\u26a0\ufe0f ${event.name || '\u5de5\u5177'}: ${errorMsg}`);
1142
+ renderer.addToolResult(event.name || '\u5de5\u5177', false, undefined, errorMsg, event.callId, undefined, cachedDesc);
1143
+ }
1144
+ else if (!event.isError && !shouldSuppress()) {
1145
+ renderer.addToolResult(event.name || '\u5de5\u5177', true, event.result, undefined, event.callId, undefined, cachedDesc);
1000
1146
  }
1001
1147
  }
1002
1148
  // 运行时错误(Codex: turn.failed / item error)
1003
1149
  if (event.type === 'error') {
1004
1150
  logger.warn(`[MessageProcessor] error event: ${event.errorType}: ${event.error}`);
1005
- if (!hasErrorResult && !shouldSuppress()) {
1151
+ // 记录错误文本到 lastReplyText,供后续 isPromptTooLong 检测
1152
+ lastReplyText += event.error || '';
1153
+ // 上下文过长的错误不在此处输出 notice,留给外层 isPromptTooLong 触发 auto-compact
1154
+ const isContextError = /prompt is too long|input is too long|上下文过长/i.test(event.error || '');
1155
+ if (!isContextError && !hasErrorResult && !shouldSuppress()) {
1006
1156
  hasErrorResult = true;
1007
- flusher.addActivity(`\u274c ${event.error}`);
1157
+ renderer.addNotice(`\u274c ${event.error}`, 'warn', 'runtime-error', true);
1008
1158
  }
1009
1159
  }
1010
1160
  // 完成事件
1011
1161
  // SDK 可能产生多个 complete 事件(如 subagent 或 auto-compact 二次查询),
1012
1162
  // 仅记录状态,最终 flush(true) 在流结束后统一执行
1013
1163
  if (event.type === 'complete') {
1014
- logger.debug(`[MessageProcessor] complete event: hasReceivedText=${hasReceivedText}, isError=${event.isError}, shouldSuppress=${shouldSuppress()}`);
1164
+ logger.info(`[MessageProcessor] complete event: isError=${event.isError} terminalReason=${event.terminalReason ?? 'none'} subtype=${event.subtype ?? 'none'} hasReceivedText=${hasReceivedText}`);
1015
1165
  // 自动回填会话名称
1016
1166
  if (event.sessionTitle && session.name === '默认会话') {
1017
1167
  await this.sessionManager.renameSession(session.id, event.sessionTitle);
1018
1168
  logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
1019
1169
  }
1020
1170
  // 记录完成状态 + 最后一轮回复文本(后续 complete 覆盖前序)
1021
- completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText };
1171
+ completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, usage: event.usage };
1022
1172
  // 失败且无前置错误输出:显示 errors 摘要
1023
1173
  // 但用户主动中断(新消息打断 或 /stop 命令)时不显示错误提示
1174
+ // 上下文过长的错误留给外层 isPromptTooLong 触发 auto-compact,不在此处输出
1024
1175
  const interruptReason = this.interruptedSessions.get(session.id);
1025
1176
  const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop' || interruptReason === 'recalled';
1026
- if (event.isError && !hasErrorResult && !shouldSuppress() && !isUserInterrupt) {
1177
+ const isContextTooLong = event.terminalReason === 'prompt_too_long'
1178
+ || /prompt is too long|input is too long|上下文过长/i.test(event.errors?.join(' ') || '')
1179
+ || /prompt is too long|input is too long|上下文过长/i.test(lastReplyText);
1180
+ if (event.isError && !hasErrorResult && !shouldSuppress() && !isUserInterrupt && !isContextTooLong) {
1027
1181
  const errorSummary = event.errors?.join('; ') || '\u4efb\u52a1\u6267\u884c\u5931\u8d25';
1028
1182
  // 使用 terminalReason 提供更友好的错误提示
1029
1183
  const userFriendlyMessage = event.terminalReason
1030
1184
  ? getErrorMessage(null, event.terminalReason)
1031
1185
  : `\u274c ${errorSummary}`;
1032
- flusher.addActivity(userFriendlyMessage);
1186
+ renderer.addNotice(userFriendlyMessage, 'warn', 'task-error', true);
1033
1187
  }
1034
1188
  // 中间 complete:flush 掉已有 activities(不带 isFinal),让中间结果及时显示
1035
1189
  // 最终文本留给流结束后的统一 flush(true)
1036
- if (flusher.hasContent()) {
1037
- await flusher.flushActivitiesOnly();
1190
+ if (renderer.hasContent()) {
1191
+ await renderer.flushActivitiesOnly();
1038
1192
  }
1039
1193
  }
1040
1194
  continue;
@@ -1055,7 +1209,7 @@ export class MessageProcessor {
1055
1209
  logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
1056
1210
  }
1057
1211
  // 记录完成状态
1058
- completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText };
1212
+ completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, usage: event.usage };
1059
1213
  if (event.subtype === 'success') {
1060
1214
  this.messageCache.addEvent(session.id, {
1061
1215
  type: 'completed',
@@ -1068,7 +1222,7 @@ export class MessageProcessor {
1068
1222
  });
1069
1223
  // 后台任务完成也纳入统计
1070
1224
  this.eventBus.publish({
1071
- type: 'message:completed',
1225
+ type: 'task:completed',
1072
1226
  sessionId: session.id,
1073
1227
  channel: session.channel,
1074
1228
  channelId: session.channelId,
@@ -1090,7 +1244,7 @@ export class MessageProcessor {
1090
1244
  });
1091
1245
  // 后台任务失败也纳入统计
1092
1246
  this.eventBus.publish({
1093
- type: 'message:error',
1247
+ type: 'task:error',
1094
1248
  sessionId: session.id,
1095
1249
  error: event.errors?.join('; ') || '\u672a\u77e5\u9519\u8bef',
1096
1250
  errorType: bgErrorType,
@@ -1102,9 +1256,16 @@ export class MessageProcessor {
1102
1256
  catch (error) {
1103
1257
  // User interrupt (AbortError) is expected, log at info level
1104
1258
  const catchInterruptReason = this.interruptedSessions.get(session.id);
1105
- const catchIsUserInterrupt = catchInterruptReason === 'new_message' || catchInterruptReason === 'stop';
1259
+ const catchIsUserInterrupt = catchInterruptReason === 'new_message' || catchInterruptReason === 'stop' || catchInterruptReason === 'recalled';
1106
1260
  if (error instanceof Error && error.name === 'AbortError') {
1107
1261
  logger.info('[MessageProcessor] Stream interrupted (AbortError)');
1262
+ // User-initiated interrupt: skip flush — new task takes over the channel,
1263
+ // flushing here would send a spurious "最终回复" before the new task's output
1264
+ if (catchIsUserInterrupt) {
1265
+ completeResult.isError = false;
1266
+ completeResult.hasReceivedText = hasReceivedText;
1267
+ return completeResult;
1268
+ }
1108
1269
  }
1109
1270
  else if (catchIsUserInterrupt) {
1110
1271
  // SDK telemetry noise after user-initiated interrupt — not a real error
@@ -1121,13 +1282,13 @@ export class MessageProcessor {
1121
1282
  logger.error('[MessageProcessor] Stream processing error:', error);
1122
1283
  }
1123
1284
  if (error instanceof Error && error.message.includes('process exited')) {
1124
- flusher.addActivity('\u274c Claude Code \u8fdb\u7a0b\u5f02\u5e38\u9000\u51fa\uff0c\u8bf7\u91cd\u8bd5');
1285
+ renderer.addNotice('\u274c Claude Code \u8fdb\u7a0b\u5f02\u5e38\u9000\u51fa\uff0c\u8bf7\u91cd\u8bd5', 'warn', 'process-exit', true);
1125
1286
  }
1126
1287
  // Flush any pending error activities before re-throwing,
1127
1288
  // and mark the error so outer catch won't send a duplicate message
1128
- if (hasErrorResult || flusher.hasContent()) {
1289
+ if (hasErrorResult || renderer.hasContent()) {
1129
1290
  try {
1130
- await flusher.flush(true);
1291
+ await renderer.flush(true);
1131
1292
  }
1132
1293
  catch { }
1133
1294
  if (error instanceof Error) {
@@ -1261,8 +1422,65 @@ export class MessageProcessor {
1261
1422
  if (/^[.\s\u2026]+$/.test(filePath))
1262
1423
  return true;
1263
1424
  // 含正则/代码特殊字符(Agent 在说明中引用了代码或正则表达式)
1264
- if (/[\\[\]{}*+?|^$]/.test(filePath))
1425
+ if (/[\[\]{}*+?|^$]/.test(filePath))
1265
1426
  return true;
1266
1427
  return false;
1267
1428
  }
1268
1429
  }
1430
+ // ── 出站协议辅助:buildEnvelope / sendInteractionPayload ──
1431
+ // Phase 3 of outbound unification: callers (permission flow, CommandHandler
1432
+ // interaction cards, claude-runner AskUserQuestion / ExitPlanMode) should
1433
+ // produce `{ kind: 'interaction', interaction, fallbackText }` and dispatch
1434
+ // via `adapter.send(envelope, payload)` instead of calling
1435
+ // `adapter.sendInteraction(...)` directly. These helpers centralise the
1436
+ // indirection and provide a backwards-compatible fallback path for adapters
1437
+ // that do not yet implement `send`.
1438
+ /**
1439
+ * Default fallback text for an InteractionRequest. Used when the caller
1440
+ * does not supply one explicitly. Picks the appropriate renderer based on
1441
+ * the interaction kind.
1442
+ */
1443
+ export function defaultFallbackText(interaction) {
1444
+ const kind = interaction.kind;
1445
+ if (kind.kind === 'command-card') {
1446
+ return renderCommandCardAsText(kind);
1447
+ }
1448
+ if (kind.kind === 'action') {
1449
+ try {
1450
+ return renderActionAsText(interaction);
1451
+ }
1452
+ catch {
1453
+ // ActionInteraction without fallback metadata — produce a minimal hint
1454
+ const action = kind;
1455
+ const lines = [action.title];
1456
+ if (action.body)
1457
+ lines.push(action.body);
1458
+ return lines.join('\n');
1459
+ }
1460
+ }
1461
+ return '';
1462
+ }
1463
+ /**
1464
+ * Send an interaction payload through the unified `adapter.send` entrypoint.
1465
+ *
1466
+ * Sends an interaction via adapter.send(envelope, { kind: 'interaction', ... }).
1467
+ * Returns 'sent' on success, false on failure.
1468
+ */
1469
+ export async function sendInteractionPayload(adapter, envelope, interaction, fallbackText, replyCtx) {
1470
+ const text = fallbackText ?? defaultFallbackText(interaction);
1471
+ const payload = {
1472
+ kind: 'interaction',
1473
+ interaction,
1474
+ fallbackText: text || undefined,
1475
+ };
1476
+ try {
1477
+ const enriched = replyCtx
1478
+ ? { ...envelope, replyContext: replyCtx }
1479
+ : envelope;
1480
+ await adapter.send(enriched, payload);
1481
+ return 'sent';
1482
+ }
1483
+ catch {
1484
+ return false;
1485
+ }
1486
+ }