evolclaw 2.8.1 → 2.8.2

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.
@@ -1,3 +1,94 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { logger } from '../utils/logger.js';
4
+ import { validateDefaultChannelRef } from '../config.js';
5
+ const VALID_BASEAGENTS = new Set(['claude', 'codex', 'gemini', 'hermes']);
6
+ const VALID_CHANNEL_TYPES = new Set(['feishu', 'aun', 'wechat', 'wecom', 'dingtalk', 'qqbot']);
7
+ const VALID_CHATMODES = new Set(['interactive', 'proactive']);
8
+ export function validateEvolAgentConfig(raw) {
9
+ const errors = [];
10
+ if (!raw || typeof raw !== 'object') {
11
+ return { valid: false, errors: ['config must be an object'] };
12
+ }
13
+ if (typeof raw.name !== 'string' || raw.name.trim() === '') {
14
+ errors.push('name is required and must be a non-empty string');
15
+ }
16
+ if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean') {
17
+ errors.push('enabled must be a boolean if present');
18
+ }
19
+ if (!raw.agents || typeof raw.agents !== 'object') {
20
+ errors.push('agents must be an object with exactly one baseagent block');
21
+ }
22
+ else {
23
+ const keys = Object.keys(raw.agents).filter(k => VALID_BASEAGENTS.has(k));
24
+ const unknownKeys = Object.keys(raw.agents).filter(k => !VALID_BASEAGENTS.has(k));
25
+ if (unknownKeys.length > 0) {
26
+ errors.push(`agents contains unknown baseagent keys: ${unknownKeys.join(', ')}`);
27
+ }
28
+ if (keys.length === 0) {
29
+ errors.push('agents must contain exactly one of: claude | codex | gemini | hermes');
30
+ }
31
+ else if (keys.length > 1) {
32
+ errors.push(`agents must contain exactly one baseagent (single baseagent only), got: ${keys.join(', ')}`);
33
+ }
34
+ }
35
+ if (!raw.channels || typeof raw.channels !== 'object') {
36
+ errors.push('channels is required');
37
+ }
38
+ else {
39
+ const channelKeys = Object.keys(raw.channels).filter(k => k !== 'defaultChannel');
40
+ if (channelKeys.length === 0) {
41
+ errors.push('channels must contain at least one channel type');
42
+ }
43
+ for (const key of channelKeys) {
44
+ if (!VALID_CHANNEL_TYPES.has(key)) {
45
+ errors.push(`unknown channel type: ${key}`);
46
+ }
47
+ }
48
+ // defaultChannel reference validation (same rules as evolclaw.json)
49
+ let totalInstances = 0;
50
+ for (const key of channelKeys) {
51
+ const block = raw.channels[key];
52
+ const insts = Array.isArray(block) ? block : (block ? [block] : []);
53
+ totalInstances += insts.length;
54
+ }
55
+ const dc = raw.channels.defaultChannel;
56
+ if (dc) {
57
+ const err = validateDefaultChannelRef(dc, raw.channels);
58
+ if (err)
59
+ errors.push(err);
60
+ }
61
+ else if (totalInstances > 1) {
62
+ errors.push('channels.defaultChannel is required when multiple channel instances are configured (use "type" or "type/instanceName")');
63
+ }
64
+ }
65
+ if (!raw.projects || typeof raw.projects !== 'object') {
66
+ errors.push('projects is required');
67
+ }
68
+ else {
69
+ const p = raw.projects.defaultPath;
70
+ if (typeof p !== 'string' || p === '') {
71
+ errors.push('projects.defaultPath is required');
72
+ }
73
+ else if (!path.isAbsolute(p)) {
74
+ errors.push(`projects.defaultPath must be absolute, got: ${p}`);
75
+ }
76
+ }
77
+ if (raw.chatmode !== undefined) {
78
+ if (typeof raw.chatmode !== 'object' || raw.chatmode === null) {
79
+ errors.push('chatmode must be an object if present');
80
+ }
81
+ else {
82
+ for (const key of ['private', 'group']) {
83
+ const val = raw.chatmode[key];
84
+ if (val !== undefined && !VALID_CHATMODES.has(val)) {
85
+ errors.push(`chatmode.${key} must be 'interactive' or 'proactive'`);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ return { valid: errors.length === 0, errors };
91
+ }
1
92
  export class EvolAgent {
2
93
  name;
3
94
  configPath;
@@ -28,6 +119,22 @@ export class EvolAgent {
28
119
  get projectPath() {
29
120
  return this.config.projects.defaultPath;
30
121
  }
122
+ /**
123
+ * Compute the effective channel-instance name (used as registry key, session.channel, etc).
124
+ *
125
+ * - DefaultAgent: rawName ?? type (preserves backward-compat with evolclaw.json)
126
+ * - EvolAgent:
127
+ * - rawName present → `${agent.name}-${type}-${rawName}`
128
+ * - rawName absent → `${agent.name}-${type}`
129
+ *
130
+ * The agent-name prefix avoids collisions with DefaultAgent channels, e.g.
131
+ * test-bot's aun → "test-bot-aun" instead of "aun".
132
+ */
133
+ effectiveChannelName(type, rawName) {
134
+ if (this.isDefault)
135
+ return rawName ?? type;
136
+ return rawName ? `${this.name}-${type}-${rawName}` : `${this.name}-${type}`;
137
+ }
31
138
  channelInstanceNames() {
32
139
  const names = [];
33
140
  for (const [type, raw] of Object.entries(this.config.channels || {})) {
@@ -35,11 +142,153 @@ export class EvolAgent {
35
142
  for (const inst of instances) {
36
143
  if (!inst || typeof inst !== 'object')
37
144
  continue;
38
- names.push(inst.name ?? type);
145
+ names.push(this.effectiveChannelName(type, inst.name));
39
146
  }
40
147
  }
41
148
  return names;
42
149
  }
150
+ /**
151
+ * Locate a channel-instance config block within this agent's config by
152
+ * matching the effective channel name (with agent prefix for EvolAgents).
153
+ * Returns the raw mutable instance object, or `null` if not found.
154
+ */
155
+ findChannelInstance(channelName) {
156
+ const channels = this.config.channels || {};
157
+ for (const [type, raw] of Object.entries(channels)) {
158
+ if (type === 'defaultChannel')
159
+ continue;
160
+ const instances = Array.isArray(raw) ? raw : [raw];
161
+ for (const inst of instances) {
162
+ if (!inst || typeof inst !== 'object')
163
+ continue;
164
+ const effName = this.effectiveChannelName(type, inst.name);
165
+ if (effName === channelName)
166
+ return inst;
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+ /** Get owner of a specific channel instance owned by this agent. */
172
+ getOwner(channelName) {
173
+ const inst = this.findChannelInstance(channelName);
174
+ return inst?.owner;
175
+ }
176
+ /** True when `userId` is the owner of `channelName`. */
177
+ isOwner(channelName, userId) {
178
+ return this.getOwner(channelName) === userId;
179
+ }
180
+ /**
181
+ * True when `userId` is admin (or owner) of `channelName`.
182
+ * Owner implicitly has admin rights.
183
+ */
184
+ isAdmin(channelName, userId) {
185
+ if (this.isOwner(channelName, userId))
186
+ return true;
187
+ const inst = this.findChannelInstance(channelName);
188
+ const admins = inst?.admins || [];
189
+ return admins.includes(userId);
190
+ }
191
+ /**
192
+ * Set owner for a channel instance and persist to agent.json.
193
+ * Throws when called on DefaultAgent (no configPath) — callers must use
194
+ * the global config setter for default channels.
195
+ */
196
+ setOwner(channelName, userId) {
197
+ const inst = this.findChannelInstance(channelName);
198
+ if (!inst) {
199
+ logger.warn(`[EvolAgent] setOwner: channel "${channelName}" not found in agent "${this.name}"`);
200
+ return;
201
+ }
202
+ inst.owner = userId;
203
+ this.persist();
204
+ }
205
+ /** Get showActivities mode for a channel instance owned by this agent. */
206
+ getShowActivities(channelName) {
207
+ const inst = this.findChannelInstance(channelName);
208
+ return inst?.showActivities ?? 'all';
209
+ }
210
+ /**
211
+ * Set showActivities for a channel instance and persist to agent.json.
212
+ * Throws when called on DefaultAgent — callers must use the global setter.
213
+ */
214
+ setShowActivities(channelName, mode) {
215
+ const inst = this.findChannelInstance(channelName);
216
+ if (!inst) {
217
+ logger.warn(`[EvolAgent] setShowActivities: channel "${channelName}" not found in agent "${this.name}"`);
218
+ return;
219
+ }
220
+ inst.showActivities = mode;
221
+ this.persist();
222
+ }
223
+ /**
224
+ * Set this agent's baseagent.model and persist to agent.json.
225
+ * Refuses for DefaultAgent. Writes to config.agents[baseagent].model.
226
+ */
227
+ setBaseagentModel(value) {
228
+ const ba = this.baseagent;
229
+ if (!this.config.agents[ba])
230
+ this.config.agents[ba] = {};
231
+ if (value === undefined) {
232
+ delete this.config.agents[ba].model;
233
+ }
234
+ else {
235
+ this.config.agents[ba].model = value;
236
+ }
237
+ this.persist();
238
+ }
239
+ /**
240
+ * Get the agent's project list (defaults to a single entry derived from
241
+ * projects.defaultPath when projects.list is empty/absent).
242
+ */
243
+ getProjects() {
244
+ const list = this.config.projects?.list;
245
+ if (list && Object.keys(list).length > 0)
246
+ return { ...list };
247
+ const dp = this.config.projects?.defaultPath;
248
+ if (dp)
249
+ return { [path.basename(dp)]: dp };
250
+ return {};
251
+ }
252
+ /**
253
+ * Add (or update) a named project in this agent's projects.list and persist.
254
+ * Throws for DefaultAgent (caller should write to evolclaw.json instead).
255
+ */
256
+ addProject(name, projectPath) {
257
+ if (!this.config.projects)
258
+ this.config.projects = { defaultPath: projectPath, list: {} };
259
+ if (!this.config.projects.list)
260
+ this.config.projects.list = {};
261
+ this.config.projects.list[name] = projectPath;
262
+ this.persist();
263
+ }
264
+ /**
265
+ * Set this agent's baseagent.effort and persist to agent.json.
266
+ * For codex, the field is named `reasoning` (alias). Refuses for DefaultAgent.
267
+ */
268
+ setBaseagentEffort(value) {
269
+ const ba = this.baseagent;
270
+ if (!this.config.agents[ba])
271
+ this.config.agents[ba] = {};
272
+ const fieldName = ba === 'codex' ? 'reasoning' : 'effort';
273
+ if (value === undefined) {
274
+ delete this.config.agents[ba][fieldName];
275
+ }
276
+ else {
277
+ this.config.agents[ba][fieldName] = value;
278
+ }
279
+ this.persist();
280
+ }
281
+ /**
282
+ * Persist the in-memory config back to the agent.json file.
283
+ * Refuses for DefaultAgent: it is built from evolclaw.json and has no
284
+ * dedicated file — callers must route writes through the global config.
285
+ */
286
+ persist() {
287
+ if (!this.configPath) {
288
+ throw new Error('Cannot persist DefaultAgent config; use global config setters');
289
+ }
290
+ fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2) + '\n', 'utf-8');
291
+ }
43
292
  getContext(channelName, chatType, globalChatmode) {
44
293
  const chatMode = this.resolveChatMode(chatType, globalChatmode);
45
294
  return {
@@ -16,6 +16,7 @@ export class MessageBridge {
16
16
  eventBus;
17
17
  debouncers = new Map();
18
18
  defaultDebounce;
19
+ agentRegistry;
19
20
  constructor(config, sessionManager, processor, messageQueue, cmdHandler, eventBus) {
20
21
  this.config = config;
21
22
  this.sessionManager = sessionManager;
@@ -25,6 +26,10 @@ export class MessageBridge {
25
26
  this.eventBus = eventBus;
26
27
  this.defaultDebounce = config.debounce ?? 2;
27
28
  }
29
+ /** Inject EvolAgentRegistry so owner lookups/writes route to agent.json for agent-owned channels. */
30
+ setAgentRegistry(registry) {
31
+ this.agentRegistry = registry;
32
+ }
28
33
  getDebouncer(channelName, channelType) {
29
34
  let d = this.debouncers.get(channelName);
30
35
  if (!d) {
@@ -88,7 +93,13 @@ export class MessageBridge {
88
93
  if (msg.peerName)
89
94
  metadata.peerName = msg.peerName;
90
95
  }
91
- const session = await this.sessionManager.getOrCreateSession(channelName, msg.channelId, this.config.projects?.defaultPath || process.cwd(), msg.threadId, Object.keys(metadata).length ? metadata : undefined, undefined, msg.peerId, chatType);
96
+ // Resolve effective project path: agent's projectPath when channel is agent-owned,
97
+ // otherwise fall back to global config.projects.defaultPath
98
+ const owningAgent = this.agentRegistry?.resolveByChannel(channelName);
99
+ const effectiveProjectPath = (owningAgent && !owningAgent.isDefault)
100
+ ? owningAgent.projectPath
101
+ : (this.config.projects?.defaultPath || process.cwd());
102
+ const session = await this.sessionManager.getOrCreateSession(channelName, msg.channelId, effectiveProjectPath, msg.threadId, Object.keys(metadata).length ? metadata : undefined, undefined, msg.peerId, chatType);
92
103
  // 4. 消息前缀(由 policy 决定)
93
104
  const channelInfo = this.processor.getChannelInfo?.(channelName);
94
105
  if (channelInfo?.policy) {
@@ -114,9 +125,11 @@ export class MessageBridge {
114
125
  if (fullMessage.messageId)
115
126
  adapter?.acknowledge?.(fullMessage.messageId).catch(() => { });
116
127
  const isInterrupt = chatType !== 'group';
128
+ const enqueueAgentName = (owningAgent && !owningAgent.isDefault) ? owningAgent.name : '[default]';
117
129
  const doEnqueue = async (m) => {
118
130
  return this.messageQueue.enqueue(session.id, m, session.projectPath, {
119
131
  interruptible: isInterrupt,
132
+ agentName: enqueueAgentName,
120
133
  });
121
134
  };
122
135
  if (isInterrupt) {
@@ -192,12 +205,19 @@ export class MessageBridge {
192
205
  }
193
206
  /** 首次交互自动绑定 owner */
194
207
  async autoBindOwner(channel, userId) {
208
+ // Registry-first: route owner queries/writes to the agent that owns this channel.
209
+ // Falls back to evolclaw.json for default-agent channels.
195
210
  const { getOwner, setOwner } = await import('../../config.js');
196
- const currentOwner = getOwner(this.config, channel);
211
+ const currentOwner = this.agentRegistry?.getOwner?.(channel) ?? getOwner(this.config, channel);
197
212
  // currentOwner === undefined means either no owner set, or instance not found
198
213
  // In both cases, try to set — setOwner is a no-op for unknown instances
199
214
  if (currentOwner === undefined) {
200
- setOwner(this.config, channel, userId);
215
+ if (this.agentRegistry?.setChannelOwner) {
216
+ this.agentRegistry.setChannelOwner(channel, userId);
217
+ }
218
+ else {
219
+ setOwner(this.config, channel, userId);
220
+ }
201
221
  logger.info(`[Owner] Auto-bound ${channel} owner: ${userId}`);
202
222
  this.eventBus.publish({ type: 'channel:owner-bound', channel, userId });
203
223
  }
@@ -11,7 +11,7 @@ import { summarizeToolInput } from '../permission.js';
11
11
  import { DEFAULT_PERMISSION_MODE } from '../../types.js';
12
12
  import { getOwner } from '../../config.js';
13
13
  import { getPackageRoot, resolveRoot } from '../../paths.js';
14
- import { renderPromptSection } from '../../prompts/templates.js';
14
+ import { renderPromptSection } from '../../agents/templates.js';
15
15
  /**
16
16
  * 统一消息处理器
17
17
  * 负责处理来自不同渠道的消息,协调事件流处理
@@ -77,6 +77,19 @@ export class MessageProcessor {
77
77
  setMessageQueue(queue) {
78
78
  this.messageQueue = queue;
79
79
  }
80
+ agentRegistry;
81
+ setAgentRegistry(registry) {
82
+ this.agentRegistry = registry;
83
+ }
84
+ getAgentContext(channelName, chatType) {
85
+ if (!this.agentRegistry)
86
+ return null;
87
+ const agent = this.agentRegistry.resolveByChannel(channelName);
88
+ if (!agent)
89
+ return null;
90
+ const globalCm = this.config.chatmode;
91
+ return agent.getContext(channelName, chatType, globalCm);
92
+ }
80
93
  /**
81
94
  * 注册渠道适配器
82
95
  */
@@ -157,6 +170,11 @@ export class MessageProcessor {
157
170
  const streamKey = session.id;
158
171
  const chatType = message.chatType || 'private';
159
172
  const identityRole = session.identity?.role || 'anonymous';
173
+ // Resolve agent context from registry (Phase 2 foundation)
174
+ const agentContext = this.getAgentContext(channelKey, chatType);
175
+ if (agentContext) {
176
+ logger.debug(`[MessageProcessor] Agent context resolved: ${agentContext.name} (${agentContext.baseagent})`);
177
+ }
160
178
  // 按 session.agentId 选择 agent 后端
161
179
  const agent = this.getAgent(session.agentId);
162
180
  const monitorEnabled = this.config.idleMonitor?.enabled !== false;
@@ -270,6 +288,8 @@ export class MessageProcessor {
270
288
  const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
271
289
  const channelKey = session.metadata?.channelName || message.channel;
272
290
  const channelInfo = this.resolveChannelInfo(channelKey);
291
+ // Per-method agent name for stats bucketing (agent.name or '[default]')
292
+ const agentNameForStats = this.agentRegistry?.resolveByChannel(channelKey)?.name ?? '[default]';
273
293
  if (!channelInfo) {
274
294
  logger.error(`[MessageProcessor] Unknown channel: ${channelKey}`);
275
295
  return;
@@ -312,6 +332,7 @@ export class MessageProcessor {
312
332
  channel: message.channel,
313
333
  channelId: message.channelId,
314
334
  content: message.content,
335
+ agentName: agentNameForStats,
315
336
  timestamp: Date.now()
316
337
  });
317
338
  const imageInfo = message.images && message.images.length > 0 ? ` [${message.images.length} image(s)]` : '';
@@ -584,7 +605,7 @@ export class MessageProcessor {
584
605
  if (isCrossChannel) {
585
606
  const targetAdapterName = targetInfo.adapter.channelName;
586
607
  const targetChannelType = targetInfo.options?.channelType || targetAdapterName;
587
- const ownerPeerId = getOwner(this.config, targetAdapterName);
608
+ const ownerPeerId = this.agentRegistry?.getOwner?.(targetAdapterName) ?? getOwner(this.config, targetAdapterName);
588
609
  targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannelType, ownerPeerId) ?? '') : '';
589
610
  if (!targetChannelId) {
590
611
  await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, taskReplyContext());
@@ -642,6 +663,12 @@ export class MessageProcessor {
642
663
  // 清除处理中状态
643
664
  this.sessionManager.clearProcessing(session.id);
644
665
  logger.info(`[MessageProcessor] session ${session.id} processing cleared task=${taskId}`);
666
+ // 更新 EvolAgent.lastActivity
667
+ if (this.agentRegistry) {
668
+ const owningAgent = this.agentRegistry.resolveByChannel(channelKey);
669
+ if (owningAgent)
670
+ owningAgent.lastActivity = Date.now();
671
+ }
645
672
  // 注意:不在此处清除 interruptedSessions,由下一条消息的 prompt 包装逻辑消费
646
673
  const interruptReason = this.interruptedSessions.get(session.id);
647
674
  if (streamResult.isError) {
@@ -655,6 +682,7 @@ export class MessageProcessor {
655
682
  sessionId: session.id,
656
683
  error: errorSummary,
657
684
  errorType,
685
+ agentName: agentNameForStats,
658
686
  terminalReason: streamResult.terminalReason
659
687
  });
660
688
  // 系统级 subtype 仍累计错误计数,供 /status 诊断使用
@@ -687,6 +715,7 @@ export class MessageProcessor {
687
715
  terminalReason: streamResult.terminalReason,
688
716
  finalText: streamResult.lastReplyText || undefined,
689
717
  durationMs: Date.now() - startTime,
718
+ agentName: agentNameForStats,
690
719
  timestamp: Date.now()
691
720
  });
692
721
  // 记录处理完成
@@ -749,7 +778,8 @@ export class MessageProcessor {
749
778
  type: 'message:error',
750
779
  sessionId: session.id,
751
780
  error: errorMsg,
752
- errorType
781
+ errorType,
782
+ agentName: agentNameForStats,
753
783
  });
754
784
  // 记录处理失败
755
785
  logger.message({
@@ -822,6 +852,8 @@ export class MessageProcessor {
822
852
  * SDK 事件 → AgentEvent 的转换在 AgentRunner.transformStream() 中完成。
823
853
  */
824
854
  async processEventStream(stream, session, flusher, resetTimer, shouldSuppress, thoughtEmitter) {
855
+ // Per-session agent name for stats bucketing
856
+ const agentNameForStats = this.agentRegistry?.resolveByChannel(session.metadata?.channelName || session.channel)?.name ?? '[default]';
825
857
  let hasReceivedText = false;
826
858
  let hasErrorResult = false; // 是否已有 tool_result/error 事件输出过错误
827
859
  let completeResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
@@ -942,6 +974,7 @@ export class MessageProcessor {
942
974
  toolName: event.name,
943
975
  isError: event.isError,
944
976
  content: event.result,
977
+ agentName: agentNameForStats,
945
978
  timestamp: Date.now()
946
979
  });
947
980
  if (event.isError && !shouldSuppress()) {
@@ -1027,6 +1060,7 @@ export class MessageProcessor {
1027
1060
  channelId: session.channelId,
1028
1061
  finalText: lastReplyText || event.result || undefined,
1029
1062
  durationMs: event.durationMs,
1063
+ agentName: agentNameForStats,
1030
1064
  timestamp: Date.now()
1031
1065
  });
1032
1066
  }
@@ -1045,7 +1079,8 @@ export class MessageProcessor {
1045
1079
  type: 'message:error',
1046
1080
  sessionId: session.id,
1047
1081
  error: event.errors?.join('; ') || '\u672a\u77e5\u9519\u8bef',
1048
- errorType: bgErrorType
1082
+ errorType: bgErrorType,
1083
+ agentName: agentNameForStats,
1049
1084
  });
1050
1085
  }
1051
1086
  }
@@ -1,8 +1,10 @@
1
1
  import path from 'path';
2
2
  import { logger } from '../../utils/logger.js';
3
+ const DEFAULT_AGENT_NAME = '[default]';
3
4
  export class MessageQueue {
4
5
  queues = new Map();
5
6
  processing = new Set();
7
+ processingAgent = new Map(); // queueKey → agentName(处理中项目的 agent)
6
8
  externalLocks = new Map();
7
9
  handler;
8
10
  currentSessionKey;
@@ -77,18 +79,24 @@ export class MessageQueue {
77
79
  return Promise.resolve();
78
80
  }
79
81
  const queueKey = this.getQueueKey(sessionKey, projectPath);
80
- logger.debug(`[Queue] Enqueuing message for ${queueKey}`);
82
+ const agentName = options?.agentName || DEFAULT_AGENT_NAME;
83
+ logger.debug(`[Queue] Enqueuing message for ${queueKey} (agent=${agentName})`);
81
84
  return new Promise((resolve, reject) => {
82
85
  if (!this.queues.has(queueKey)) {
83
86
  this.queues.set(queueKey, []);
84
87
  }
85
- this.queues.get(queueKey).push({ message, projectPath, resolve, reject });
88
+ this.queues.get(queueKey).push({ message, projectPath, agentName, resolve, reject });
86
89
  // 根据 interruptible 选项决定是否触发中断
87
90
  if (this.processing.has(queueKey)) {
88
91
  if (options?.interruptible !== false) {
89
92
  // 单聊:保留中断行为
90
93
  logger.debug(`[Queue] ${queueKey} is processing, triggering interrupt`);
91
- this.eventBus?.publish({ type: 'message:interrupted', sessionId: sessionKey, reason: 'new_message' });
94
+ this.eventBus?.publish({
95
+ type: 'message:interrupted',
96
+ sessionId: sessionKey,
97
+ reason: 'new_message',
98
+ agentName: this.processingAgent.get(queueKey),
99
+ });
92
100
  if (this.interruptCallback) {
93
101
  this.interruptCallback(sessionKey, this.currentAgentId).catch(() => { });
94
102
  }
@@ -118,6 +126,7 @@ export class MessageQueue {
118
126
  if (!queue || queue.length === 0) {
119
127
  logger.debug(`[Queue] Queue ${queueKey} is empty, stopping`);
120
128
  this.processing.delete(queueKey);
129
+ this.processingAgent.delete(queueKey);
121
130
  this.currentSessionKey = undefined;
122
131
  this.currentProjectPath = undefined;
123
132
  this.activeMessageIds.clear();
@@ -129,6 +138,7 @@ export class MessageQueue {
129
138
  this.currentSessionKey = queueKey;
130
139
  this.currentProjectPath = merged.projectPath;
131
140
  this.currentAgentId = merged.message.agentId;
141
+ this.processingAgent.set(queueKey, merged.agentName);
132
142
  // 记录正在执行的 messageId(用于撤回中断)
133
143
  this.activeMessageIds.clear();
134
144
  for (const item of items) {
@@ -203,6 +213,7 @@ export class MessageQueue {
203
213
  return {
204
214
  message: merged,
205
215
  projectPath: last.projectPath,
216
+ agentName: last.agentName,
206
217
  resolve: () => { }, // 由调用方管理
207
218
  reject: () => { },
208
219
  };
@@ -226,6 +237,20 @@ export class MessageQueue {
226
237
  }
227
238
  return false;
228
239
  }
240
+ /**
241
+ * 检查指定 channel 下是否有任何 session 在处理。
242
+ * queueKey 格式为 `${sessionKey}::${projectPath}`,其中 sessionKey
243
+ * 形如 `${channelName}-${channelId}-${ts}`,因此匹配 `${channelName}-` 前缀。
244
+ */
245
+ isChannelProcessing(channelName) {
246
+ const prefix = `${channelName}-`;
247
+ for (const key of this.processing.keys()) {
248
+ if (key.startsWith(prefix) || key.startsWith(`${channelName}::`)) {
249
+ return true;
250
+ }
251
+ }
252
+ return false;
253
+ }
229
254
  cancel(messageId) {
230
255
  for (const queue of this.queues.values()) {
231
256
  const idx = queue.findIndex(q => q.message.messageId === messageId);
@@ -250,7 +275,12 @@ export class MessageQueue {
250
275
  // 从 queueKey 提取 sessionKey
251
276
  const sessionKey = this.currentSessionKey.split('::')[0];
252
277
  logger.info(`[Queue] Recalled active message ${messageId}, interrupting session ${sessionKey}`);
253
- this.eventBus?.publish({ type: 'message:interrupted', sessionId: sessionKey, reason: 'recalled' });
278
+ this.eventBus?.publish({
279
+ type: 'message:interrupted',
280
+ sessionId: sessionKey,
281
+ reason: 'recalled',
282
+ agentName: this.processingAgent.get(this.currentSessionKey),
283
+ });
254
284
  if (this.interruptCallback) {
255
285
  this.interruptCallback(sessionKey, this.currentAgentId).catch(() => { });
256
286
  }
@@ -293,4 +323,29 @@ export class MessageQueue {
293
323
  getGlobalProcessingCount() {
294
324
  return this.processing.size;
295
325
  }
326
+ /**
327
+ * 获取指定 agent 的待处理消息数量。
328
+ * agent 维度按 enqueue 时传入的 agentName 计数。
329
+ */
330
+ getQueueLengthByAgent(agentName) {
331
+ let total = 0;
332
+ for (const queue of this.queues.values()) {
333
+ for (const item of queue) {
334
+ if ((item.agentName || DEFAULT_AGENT_NAME) === agentName)
335
+ total++;
336
+ }
337
+ }
338
+ return total;
339
+ }
340
+ /**
341
+ * 获取指定 agent 的处理中队列数量。
342
+ */
343
+ getProcessingCountByAgent(agentName) {
344
+ let total = 0;
345
+ for (const a of this.processingAgent.values()) {
346
+ if ((a || DEFAULT_AGENT_NAME) === agentName)
347
+ total++;
348
+ }
349
+ return total;
350
+ }
296
351
  }