evolclaw 2.8.1 → 2.8.3

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.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Reload Hooks
3
+ *
4
+ * Extracted from index.ts main() for testability. Builds the ReloadHooks
5
+ * implementation used by AgentRegistry.reload() to drain/disconnect/start
6
+ * channels during a hot reload.
7
+ */
8
+ import { logger } from '../utils/logger.js';
9
+ export function buildReloadHooks(deps) {
10
+ const { channelLoader, channelInstances, registerChannelInstance, messageQueue } = deps;
11
+ const drainDelayMs = deps.drainDelayMs ?? 500;
12
+ const drainTimeoutMs = deps.drainTimeoutMs ?? 30000;
13
+ return {
14
+ async drainChannel(channelName) {
15
+ logger.info(`[Reload] Draining channel: ${channelName}`);
16
+ if (messageQueue) {
17
+ // Real drain: poll until empty or timeout
18
+ const pollMs = 100;
19
+ const start = Date.now();
20
+ while (messageQueue.isChannelProcessing(channelName)) {
21
+ if (Date.now() - start > drainTimeoutMs) {
22
+ logger.warn(`[Reload] Drain timeout (${drainTimeoutMs}ms) for channel: ${channelName}, proceeding anyway`);
23
+ return;
24
+ }
25
+ await new Promise(r => setTimeout(r, pollMs));
26
+ }
27
+ logger.info(`[Reload] Drain complete: ${channelName}`);
28
+ }
29
+ else if (drainDelayMs > 0) {
30
+ await new Promise(r => setTimeout(r, drainDelayMs));
31
+ }
32
+ },
33
+ async disconnectChannel(channelName) {
34
+ const inst = channelInstances.find(i => i.adapter.channelName === channelName);
35
+ if (!inst) {
36
+ logger.warn(`[Reload] Channel ${channelName} not found, skipping disconnect`);
37
+ return;
38
+ }
39
+ try {
40
+ await inst.disconnect();
41
+ const idx = channelInstances.indexOf(inst);
42
+ if (idx >= 0)
43
+ channelInstances.splice(idx, 1);
44
+ logger.info(`[Reload] Disconnected channel: ${channelName}`);
45
+ }
46
+ catch (e) {
47
+ logger.error(`[Reload] Failed to disconnect ${channelName}: ${e}`);
48
+ throw e;
49
+ }
50
+ },
51
+ async startChannel(agent, channelName) {
52
+ const channels = agent.config.channels;
53
+ let channelType = null;
54
+ for (const [type, raw] of Object.entries(channels)) {
55
+ const instances = Array.isArray(raw) ? raw : [raw];
56
+ for (const inst of instances) {
57
+ const name = inst.name ?? type;
58
+ if (name === channelName) {
59
+ channelType = type;
60
+ break;
61
+ }
62
+ }
63
+ if (channelType)
64
+ break;
65
+ }
66
+ if (!channelType) {
67
+ const msg = `[Reload] Channel ${channelName} not found in agent ${agent.name} config`;
68
+ logger.error(msg);
69
+ throw new Error(msg);
70
+ }
71
+ const partialConfig = {
72
+ agents: agent.config.agents,
73
+ channels: { [channelType]: channels[channelType] },
74
+ projects: agent.config.projects,
75
+ };
76
+ const newInstances = await channelLoader.createAll(partialConfig);
77
+ const newInst = newInstances.find(i => i.adapter.channelName === channelName);
78
+ if (!newInst) {
79
+ throw new Error(`[Reload] Failed to create instance ${channelName}`);
80
+ }
81
+ registerChannelInstance(newInst);
82
+ await newInst.connect();
83
+ channelInstances.push(newInst);
84
+ logger.info(`[Reload] Started channel: ${channelName}`);
85
+ },
86
+ };
87
+ }
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ClaudeSessionFileAdapter } from './core/session/adapters/claude-session-file-adapter.js';
2
2
  import { CodexSessionFileAdapter } from './core/session/adapters/codex-session-file-adapter.js';
3
3
  import { GeminiSessionFileAdapter } from './core/session/adapters/gemini-session-file-adapter.js';
4
- import { loadConfig, ensureDataDirs, resolvePaths, resolveAnthropicConfig, isOwner, isAdmin, validateConfigIntegrity, validateChannelInstanceNames, getOwner, getDefaultSessionMode } from './config.js';
4
+ import { loadConfig, ensureDataDirs, resolvePaths, resolveAnthropicConfig, isOwner, isAdmin, validateConfigIntegrity, validateChannelInstanceNames, getOwner, getDefaultSessionMode, setOwner as setOwnerInGlobalConfig, setChannelShowActivities as setShowActivitiesInGlobalConfig } from './config.js';
5
5
  import { SessionManager } from './core/session/session-manager.js';
6
6
  import { ClaudeAgentPlugin } from './agents/claude-runner.js';
7
7
  import { CodexAgentPlugin } from './agents/codex-runner.js';
@@ -23,10 +23,12 @@ import { PermissionGateway } from './core/permission.js';
23
23
  import { InteractionRouter } from './core/interaction-router.js';
24
24
  import { ChannelLoader } from './core/channel-loader.js';
25
25
  import { AgentLoader } from './core/agent-loader.js';
26
+ import { EvolAgentRegistry } from './core/evolagent-registry.js';
27
+ import { buildReloadHooks } from './utils/reload-hooks.js';
26
28
  import { IpcServer } from './ipc.js';
27
29
  import { logger, setLogLevel } from './utils/logger.js';
28
- import { detectDuplicates } from './utils/channel-fingerprint.js';
29
- import { loadPromptTemplates } from './prompts/templates.js';
30
+ import { detectDuplicates } from './core/evolagent-registry.js';
31
+ import { loadPromptTemplates } from './agents/templates.js';
30
32
  import path from 'path';
31
33
  import fs from 'fs';
32
34
  async function main() {
@@ -82,13 +84,45 @@ async function main() {
82
84
  if (anthropic.baseUrl) {
83
85
  logger.info(`✓ Using custom API base URL: ${anthropic.baseUrl}`);
84
86
  }
87
+ // EvolAgent Registry
88
+ const agentRegistry = new EvolAgentRegistry(paths.agentsDir, {
89
+ setOwner: (channelName, userId) => {
90
+ setOwnerInGlobalConfig(config, channelName, userId);
91
+ },
92
+ setShowActivities: (channelName, mode) => {
93
+ setShowActivitiesInGlobalConfig(config, channelName, mode);
94
+ },
95
+ });
96
+ agentRegistry.loadAll(config);
97
+ const agentInfos = agentRegistry.list();
98
+ const evolagentCount = agentInfos.filter(i => !i.isDefault).length;
99
+ if (evolagentCount > 0) {
100
+ logger.info(`✓ Loaded ${evolagentCount} evolagent(s)`);
101
+ for (const info of agentInfos) {
102
+ if (info.isDefault)
103
+ continue;
104
+ if (info.status === 'error') {
105
+ logger.error(` ✗ [${info.name}] ${info.error}`);
106
+ }
107
+ else if (info.status === 'disabled') {
108
+ logger.info(` ○ [${info.name}] disabled`);
109
+ }
110
+ else {
111
+ logger.info(` ● [${info.name}] ${info.baseagent} @ ${path.basename(info.projectPath)}`);
112
+ }
113
+ }
114
+ }
115
+ // Store for IPC access (T10 will wire this)
116
+ // M4: removed dead globalThis.__evolclaw_agentRegistry assignment
85
117
  // 创建事件总线
86
118
  const eventBus = new EventBus();
87
119
  logger.info('✓ Event bus initialized');
88
120
  // 统计收集器(近 1 小时滚动统计)
89
121
  const statsCollector = new StatsCollector(eventBus);
90
122
  // 初始化数据库(带 ownerResolver)
91
- const sessionManager = new SessionManager(undefined, eventBus, (channel, userId) => isOwner(config, channel, userId), (channel, userId) => isAdmin(config, channel, userId));
123
+ // Registry-first: agent-owned channels resolve via EvolAgent.isOwner/isAdmin,
124
+ // default-agent channels fall back to global config (evolclaw.json).
125
+ const sessionManager = new SessionManager(undefined, eventBus, (channel, userId) => agentRegistry.isOwner(channel, userId, (ch, uid) => isOwner(config, ch, uid)), (channel, userId) => agentRegistry.isAdmin(channel, userId, (ch, uid) => isAdmin(config, ch, uid)));
92
126
  // sessionMode 解析:全局 chatmode 配置 > 默认 'interactive'
93
127
  sessionManager.setSessionModeResolver((_channel, chatType) => {
94
128
  return getDefaultSessionMode(config, chatType);
@@ -98,27 +132,28 @@ async function main() {
98
132
  sessionManager.registerFileAdapter(new ClaudeSessionFileAdapter());
99
133
  sessionManager.registerFileAdapter(new CodexSessionFileAdapter());
100
134
  sessionManager.registerFileAdapter(new GeminiSessionFileAdapter());
101
- // Agent 插件系统
135
+ // Agent 插件系统:每个 EvolAgent × 每个 baseagent 一个独立 runner(H1/H2 修复)
102
136
  const agentLoader = new AgentLoader();
103
137
  agentLoader.register(new ClaudeAgentPlugin());
104
138
  agentLoader.register(new CodexAgentPlugin());
105
139
  agentLoader.register(new GeminiAgentPlugin());
106
- const agentInstances = agentLoader.createAll(config, {
140
+ const agentInstances = agentLoader.createAll(config, agentRegistry, {
107
141
  onSessionIdUpdate: async (sessionId, agentSessionId) => {
108
142
  await sessionManager.updateAgentSessionIdBySessionId(sessionId, agentSessionId);
109
143
  },
110
144
  });
111
- // 构建 agent map,支持按 agentId 路由(当前默认使用第一个 agent)
145
+ // agentMap 复合键:${evolagentName}::${baseagent}
112
146
  const agentMap = new Map();
113
147
  for (const inst of agentInstances) {
114
- agentMap.set(inst.agent.name, inst.agent);
148
+ agentMap.set(`${inst.evolagentName}::${inst.baseagent}`, inst.agent);
115
149
  }
116
150
  const defaultAgent = config.agents?.defaultAgent || 'claude';
117
- const agentRunner = agentMap.get(defaultAgent) || agentInstances[0]?.agent;
151
+ const defaultAgentKey = `[default]::${defaultAgent}`;
152
+ const agentRunner = agentMap.get(defaultAgentKey) || agentInstances[0]?.agent;
118
153
  if (!agentRunner) {
119
- throw new Error('No agent backend available. Check agents config.');
154
+ throw new Error('No agent backend available. Check agents config (no runners created).');
120
155
  }
121
- logger.info(`✓ Agent runner ready (default: ${agentRunner.name}, available: ${[...agentMap.keys()].join(', ')})`);
156
+ logger.info(`✓ Runners ready (default key: ${defaultAgentKey}, total: ${agentMap.size}, keys: ${[...agentMap.keys()].join(', ')})`);
122
157
  // 权限审批网关
123
158
  const permissionGateway = new PermissionGateway();
124
159
  permissionGateway.setEventBus(eventBus);
@@ -143,12 +178,52 @@ async function main() {
143
178
  channelLoader.register(new DingtalkChannelPlugin());
144
179
  channelLoader.register(new QQBotChannelPlugin());
145
180
  channelLoader.register(new WecomChannelPlugin());
146
- const channelInstances = await channelLoader.createAll(config);
147
- logger.info(`✓ Created ${channelInstances.length} channel instance(s)`);
181
+ // Create channel instances: default (from evolclaw.json) + each evolagent
182
+ const defaultInstances = await channelLoader.createAll(config);
183
+ const evolagentInstances = [];
184
+ for (const agent of agentRegistry.runnableAgents()) {
185
+ // Rewrite channel instance names with agent prefix to avoid collisions
186
+ // with DefaultAgent and other EvolAgents.
187
+ // Rule (EvolAgent only):
188
+ // - explicit name → `${agent.name}-${type}-${name}`
189
+ // - omitted name → `${agent.name}-${type}`
190
+ const rewrittenChannels = {};
191
+ for (const [type, raw] of Object.entries(agent.config.channels || {})) {
192
+ if (type === 'defaultChannel') {
193
+ rewrittenChannels[type] = raw;
194
+ continue;
195
+ }
196
+ const instances = Array.isArray(raw) ? raw : [raw];
197
+ const rewritten = instances.map((inst) => {
198
+ if (!inst || typeof inst !== 'object')
199
+ return inst;
200
+ const effName = agent.effectiveChannelName(type, inst.name);
201
+ return { ...inst, name: effName };
202
+ });
203
+ // Preserve original shape (array vs single object)
204
+ rewrittenChannels[type] = Array.isArray(raw) ? rewritten : rewritten[0];
205
+ }
206
+ const agentConfig = {
207
+ agents: agent.config.agents,
208
+ channels: rewrittenChannels,
209
+ projects: agent.config.projects,
210
+ };
211
+ try {
212
+ const instances = await channelLoader.createAll(agentConfig);
213
+ evolagentInstances.push(...instances);
214
+ }
215
+ catch (e) {
216
+ logger.error(`[EvolAgent] Failed to create channels for ${agent.name}: ${e}`);
217
+ agent.status = 'error';
218
+ agent.error = `Channel creation failed: ${e}`;
219
+ }
220
+ }
221
+ const channelInstances = [...defaultInstances, ...evolagentInstances];
222
+ logger.info(`✓ Created ${channelInstances.length} channel instance(s)${evolagentInstances.length > 0 ? ` (${defaultInstances.length} default + ${evolagentInstances.length} agent)` : ''}`);
148
223
  // 启动迁移:将 sessions.channel 从 channelType 回填为实例名
149
224
  sessionManager.migrateChannelToInstanceName();
150
225
  // 创建命令处理器
151
- const cmdHandler = new CommandHandler(sessionManager, agentMap, config, messageCache, eventBus, defaultAgent);
226
+ const cmdHandler = new CommandHandler(sessionManager, agentMap, config, messageCache, eventBus, defaultAgentKey);
152
227
  cmdHandler.setPermissionGateway(permissionGateway);
153
228
  cmdHandler.setInteractionRouter(interactionRouter);
154
229
  cmdHandler.setStatsCollector(statsCollector);
@@ -163,9 +238,16 @@ async function main() {
163
238
  }
164
239
  };
165
240
  return cmdHandler.handle(content, channel, channelId, sendFn, userId, threadId);
166
- }, defaultAgent);
241
+ }, defaultAgentKey);
167
242
  // 回填 processor 和 messageQueue 的引用
168
243
  cmdHandler.setProcessor(processor);
244
+ // Inject EvolAgentRegistry (methods added by T6/T7)
245
+ if (processor.setAgentRegistry) {
246
+ processor.setAgentRegistry(agentRegistry);
247
+ }
248
+ if (cmdHandler.setAgentRegistry) {
249
+ cmdHandler.setAgentRegistry(agentRegistry);
250
+ }
169
251
  // 设置交互路由器
170
252
  processor.setInteractionRouter(interactionRouter);
171
253
  // 设置 compact 开始回调(对所有支持的 agent)
@@ -179,8 +261,11 @@ async function main() {
179
261
  await processor.processMessage(message);
180
262
  });
181
263
  // 设置中断回调(精确中断正在处理的 agent)
182
- messageQueue.setInterruptCallback(async (sessionKey, agentId) => {
183
- const agent = agentMap.get(agentId || defaultAgent);
264
+ messageQueue.setInterruptCallback(async (sessionKey, agentId, evolagentName) => {
265
+ const baseagent = agentId || defaultAgent;
266
+ const evol = evolagentName || '[default]';
267
+ const agent = agentMap.get(`${evol}::${baseagent}`)
268
+ || agentMap.get(defaultAgentKey);
184
269
  if (agent?.hasActiveStream(sessionKey)) {
185
270
  await agent.interrupt(sessionKey);
186
271
  }
@@ -203,12 +288,18 @@ async function main() {
203
288
  };
204
289
  // ── MessageBridge:Channel ↔ Core 消息桥梁 ──
205
290
  const msgBridge = new MessageBridge(config, sessionManager, processor, messageQueue, cmdHandler, eventBus);
291
+ msgBridge.setAgentRegistry(agentRegistry);
206
292
  // ── Channel instance registration (shared by startup and hot-load) ──
207
293
  function registerChannelInstance(inst) {
208
294
  // 1. 项目路径提供器
209
295
  if (inst.onProjectPathRequest && inst.channel.onProjectPathRequest) {
210
296
  inst.channel.onProjectPathRequest(async (channelId) => {
211
- const session = await sessionManager.getOrCreateSession(inst.adapter.channelName, channelId, config.projects?.defaultPath || process.cwd(), undefined, undefined, undefined, undefined);
297
+ // Effective default path: agent's projectPath if agent-owned, else global
298
+ const owningAgent = agentRegistry.resolveByChannel(inst.adapter.channelName);
299
+ const effectiveDefault = (owningAgent && !owningAgent.isDefault)
300
+ ? owningAgent.projectPath
301
+ : (config.projects?.defaultPath || process.cwd());
302
+ const session = await sessionManager.getOrCreateSession(inst.adapter.channelName, channelId, effectiveDefault, undefined, undefined, undefined, undefined);
212
303
  return path.isAbsolute(session.projectPath)
213
304
  ? session.projectPath
214
305
  : path.resolve(process.cwd(), session.projectPath);
@@ -350,6 +441,18 @@ async function main() {
350
441
  }
351
442
  // ── 连接所有渠道 ──
352
443
  const connected = await channelLoader.connectAll(channelInstances);
444
+ // Bind connected adapters to their owning agents
445
+ // I1: only mark 'running' if a channel actually connected for that agent
446
+ const connectedSet = new Set(connected);
447
+ for (const inst of channelInstances) {
448
+ const agent = agentRegistry.resolveByChannel(inst.adapter.channelName);
449
+ if (!agent || agent.status === 'error')
450
+ continue;
451
+ agent.channels.set(inst.adapter.channelName, inst.adapter);
452
+ if (agent.status === 'stopped' && connectedSet.has(inst.adapter.channelName)) {
453
+ agent.status = 'running';
454
+ }
455
+ }
353
456
  // 预填充 Feishu 已知 thread_id(重启后避免误判话题创建)
354
457
  for (const inst of channelInstances) {
355
458
  const channelType = inst.channelType || inst.adapter.channelName;
@@ -384,7 +487,7 @@ async function main() {
384
487
  continue; // 跳过同类型通道
385
488
  if (notified.has(otherType))
386
489
  continue; // 同类型已通知过
387
- const ownerId = getOwner(config, other.adapter.channelName);
490
+ const ownerId = agentRegistry.getOwner(other.adapter.channelName) ?? getOwner(config, other.adapter.channelName);
388
491
  if (!ownerId)
389
492
  continue;
390
493
  notified.add(otherType);
@@ -423,12 +526,16 @@ async function main() {
423
526
  sessionManager.clearProcessing(session.id);
424
527
  continue;
425
528
  }
426
- const agent = agentMap.get(session.agentId) || agentMap.get(defaultAgent);
529
+ // 复合键:${evolagentName}::${baseagent},从 channel 反查 evolagent
530
+ const owningAgent = agentRegistry.resolveByChannel(session.channel);
531
+ const evolName = owningAgent?.name || '[default]';
532
+ const baseagentName = session.agentId || defaultAgent;
533
+ const agent = agentMap.get(`${evolName}::${baseagentName}`) || agentMap.get(defaultAgentKey);
427
534
  if (!agent) {
428
535
  sessionManager.clearProcessing(session.id);
429
536
  continue;
430
537
  }
431
- logger.info(`[Resume] Resuming session: ${session.id} (agent: ${session.agentId})`);
538
+ logger.info(`[Resume] Resuming session: ${session.id} (agent: ${evolName}::${baseagentName})`);
432
539
  const resumeMessage = {
433
540
  channel: session.channel,
434
541
  channelId: session.channelId,
@@ -499,6 +606,18 @@ async function main() {
499
606
  },
500
607
  };
501
608
  }, async (cmd, sessionId) => cmdHandler.handleCtl(cmd, sessionId));
609
+ // M3: direct call (not cast) — wire EvolAgentRegistry into IPC for evolagent.* handlers
610
+ ipcServer.setAgentRegistry(agentRegistry);
611
+ // ── Reload hooks: enable agentRegistry.reload() to drain/disconnect/restart channels ──
612
+ const reloadHooks = buildReloadHooks({
613
+ channelLoader,
614
+ channelInstances,
615
+ registerChannelInstance,
616
+ messageQueue,
617
+ });
618
+ // Make reload hooks accessible to IPC handler & ctl handler (both run in this process)
619
+ globalThis.__evolclaw_reloadHooks = reloadHooks;
620
+ // I3: start IPC server LAST, after all hook setup, to eliminate race window
502
621
  ipcServer.start();
503
622
  // 运行时配置文件监控
504
623
  const configPath = resolvePaths().config;
package/dist/ipc.js CHANGED
@@ -8,11 +8,16 @@ export class IpcServer {
8
8
  getStatus;
9
9
  commandExecutor;
10
10
  server = null;
11
+ agentRegistry;
11
12
  constructor(socketPath, getStatus, commandExecutor) {
12
13
  this.socketPath = socketPath;
13
14
  this.getStatus = getStatus;
14
15
  this.commandExecutor = commandExecutor;
15
16
  }
17
+ /** Inject EvolAgentRegistry for evolagent.* IPC handlers */
18
+ setAgentRegistry(registry) {
19
+ this.agentRegistry = registry;
20
+ }
16
21
  start() {
17
22
  // Remove stale socket file (Unix only — named pipes auto-cleanup on process exit)
18
23
  if (!isNamedPipe(this.socketPath)) {
@@ -82,6 +87,48 @@ export class IpcServer {
82
87
  return { ok: false, error: 'missing cmd or sessionId' };
83
88
  return await this.commandExecutor(slashCmd, sessionId);
84
89
  }
90
+ case 'evolagent.list': {
91
+ if (!this.agentRegistry)
92
+ return { ok: false, error: 'EvolAgentRegistry not available' };
93
+ return { ok: true, agents: this.agentRegistry.list() };
94
+ }
95
+ case 'evolagent.show': {
96
+ if (!this.agentRegistry)
97
+ return { ok: false, error: 'EvolAgentRegistry not available' };
98
+ const name = cmd.name;
99
+ if (!name || typeof name !== 'string')
100
+ return { ok: false, error: 'missing name' };
101
+ const agent = this.agentRegistry.get(name);
102
+ if (!agent)
103
+ return { ok: false, error: `Agent "${name}" not found` };
104
+ const info = this.agentRegistry.list().find((i) => i.name === name);
105
+ // I7: null-guard list().find() result
106
+ if (!info)
107
+ return { ok: false, error: `Agent "${name}" found but info missing (race?)` };
108
+ return { ok: true, agent: info };
109
+ }
110
+ case 'evolagent.reload': {
111
+ if (!this.agentRegistry)
112
+ return { ok: false, error: 'EvolAgentRegistry not available' };
113
+ const name = cmd.name;
114
+ if (!name || typeof name !== 'string')
115
+ return { ok: false, error: 'missing name' };
116
+ const hooks = globalThis.__evolclaw_reloadHooks;
117
+ if (!hooks)
118
+ return { ok: false, error: 'Reload hooks not initialized' };
119
+ try {
120
+ const a = this.agentRegistry.get(name);
121
+ if (!a)
122
+ return { ok: false, error: `Agent "${name}" not found` };
123
+ if (!this.agentRegistry.reload)
124
+ return { ok: false, error: 'EvolAgentRegistry.reload not available' };
125
+ await this.agentRegistry.reload(name, hooks);
126
+ return { ok: true, result: `Agent "${name}" reloaded` };
127
+ }
128
+ catch (e) {
129
+ return { ok: false, error: e?.message || String(e) };
130
+ }
131
+ }
85
132
  default:
86
133
  return { error: `unknown command: ${cmd.type}` };
87
134
  }
package/dist/types.js CHANGED
@@ -3,3 +3,5 @@
3
3
  // Array form: `name` is required to distinguish instances.
4
4
  /** Default permission mode applied to new sessions. Change here to affect all roles. */
5
5
  export const DEFAULT_PERMISSION_MODE = 'bypass';
6
+ /** Reserved agent name used for DefaultAgent (no agent.json file). */
7
+ export const DEFAULT_AGENT_NAME = '[default]';
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Reload Hooks
3
+ *
4
+ * Extracted from index.ts main() for testability. Builds the ReloadHooks
5
+ * implementation used by EvolAgentRegistry.reload() to drain/disconnect/start
6
+ * channels during a hot reload.
7
+ */
8
+ import { logger } from './logger.js';
9
+ export function buildReloadHooks(deps) {
10
+ const { channelLoader, channelInstances, registerChannelInstance, messageQueue } = deps;
11
+ const drainDelayMs = deps.drainDelayMs ?? 500;
12
+ const drainTimeoutMs = deps.drainTimeoutMs ?? 30000;
13
+ return {
14
+ async drainChannel(channelName) {
15
+ logger.info(`[Reload] Draining channel: ${channelName}`);
16
+ if (messageQueue) {
17
+ // Real drain: poll until empty or timeout
18
+ const pollMs = 100;
19
+ const start = Date.now();
20
+ while (messageQueue.isChannelProcessing(channelName)) {
21
+ if (Date.now() - start > drainTimeoutMs) {
22
+ logger.warn(`[Reload] Drain timeout (${drainTimeoutMs}ms) for channel: ${channelName}, proceeding anyway`);
23
+ return;
24
+ }
25
+ await new Promise(r => setTimeout(r, pollMs));
26
+ }
27
+ logger.info(`[Reload] Drain complete: ${channelName}`);
28
+ }
29
+ else if (drainDelayMs > 0) {
30
+ await new Promise(r => setTimeout(r, drainDelayMs));
31
+ }
32
+ },
33
+ async disconnectChannel(channelName) {
34
+ const inst = channelInstances.find(i => i.adapter.channelName === channelName);
35
+ if (!inst) {
36
+ logger.warn(`[Reload] Channel ${channelName} not found, skipping disconnect`);
37
+ return;
38
+ }
39
+ try {
40
+ await inst.disconnect();
41
+ const idx = channelInstances.indexOf(inst);
42
+ if (idx >= 0)
43
+ channelInstances.splice(idx, 1);
44
+ logger.info(`[Reload] Disconnected channel: ${channelName}`);
45
+ }
46
+ catch (e) {
47
+ logger.error(`[Reload] Failed to disconnect ${channelName}: ${e}`);
48
+ throw e;
49
+ }
50
+ },
51
+ async startChannel(agent, channelName) {
52
+ const channels = agent.config.channels;
53
+ let channelType = null;
54
+ for (const [type, raw] of Object.entries(channels)) {
55
+ const instances = Array.isArray(raw) ? raw : [raw];
56
+ for (const inst of instances) {
57
+ const name = inst.name ?? type;
58
+ if (name === channelName) {
59
+ channelType = type;
60
+ break;
61
+ }
62
+ }
63
+ if (channelType)
64
+ break;
65
+ }
66
+ if (!channelType) {
67
+ const msg = `[Reload] Channel ${channelName} not found in agent ${agent.name} config`;
68
+ logger.error(msg);
69
+ throw new Error(msg);
70
+ }
71
+ const partialConfig = {
72
+ agents: agent.config.agents,
73
+ channels: { [channelType]: channels[channelType] },
74
+ projects: agent.config.projects,
75
+ };
76
+ const newInstances = await channelLoader.createAll(partialConfig);
77
+ const newInst = newInstances.find(i => i.adapter.channelName === channelName);
78
+ if (!newInst) {
79
+ throw new Error(`[Reload] Failed to create instance ${channelName}`);
80
+ }
81
+ registerChannelInstance(newInst);
82
+ await newInst.connect();
83
+ channelInstances.push(newInst);
84
+ logger.info(`[Reload] Started channel: ${channelName}`);
85
+ },
86
+ };
87
+ }
@@ -226,3 +226,36 @@ export async function closeBrowser() {
226
226
  browserInstance = null;
227
227
  }
228
228
  }
229
+ // ── Markdown → Plain Text ───────────────────────────────────────────────────
230
+ // 用于不渲染 markdown 的渠道(微信、QQ)将 Agent 输出降级为纯文本。
231
+ export function markdownToPlainText(text) {
232
+ let result = text;
233
+ // Code blocks: strip fences, keep content
234
+ result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code) => code.trim());
235
+ // Images: remove entirely
236
+ result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
237
+ // Links: keep display text only
238
+ result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
239
+ // Tables: remove separator rows
240
+ result = result.replace(/^\|[\s:|-]+\|$/gm, '');
241
+ result = result.replace(/^\|(.+)\|$/gm, (_, inner) => inner.split('|').map(cell => cell.trim()).join(' '));
242
+ // Bold/italic
243
+ result = result.replace(/\*\*(.+?)\*\*/g, '$1');
244
+ result = result.replace(/\*(.+?)\*/g, '$1');
245
+ result = result.replace(/__(.+?)__/g, '$1');
246
+ result = result.replace(/_(.+?)_/g, '$1');
247
+ // Strikethrough
248
+ result = result.replace(/~~(.+?)~~/g, '$1');
249
+ // Inline code
250
+ result = result.replace(/`([^`]+)`/g, '$1');
251
+ // Headers
252
+ result = result.replace(/^#{1,6}\s+/gm, '');
253
+ // Blockquotes
254
+ result = result.replace(/^>\s?/gm, '');
255
+ // Horizontal rules
256
+ result = result.replace(/^[-*_]{3,}$/gm, '');
257
+ // List markers
258
+ result = result.replace(/^(\s*)[-*+]\s/gm, '$1');
259
+ result = result.replace(/^(\s*)\d+\.\s/gm, '$1');
260
+ return result.trim();
261
+ }
@@ -7,23 +7,24 @@ export class StatsCollector {
7
7
  // 订阅相关事件
8
8
  eventBus.subscribe('message:received', (event) => {
9
9
  const e = event;
10
- this.recordEvent({ type: 'received', timestamp: e.timestamp || Date.now() });
10
+ this.recordEvent({ type: 'received', timestamp: e.timestamp || Date.now(), agentName: e.agentName });
11
11
  });
12
12
  eventBus.subscribe('message:completed', (event) => {
13
13
  const e = event;
14
- this.recordEvent({ type: 'completed', timestamp: e.timestamp || Date.now(), durationMs: e.durationMs });
14
+ this.recordEvent({ type: 'completed', timestamp: e.timestamp || Date.now(), durationMs: e.durationMs, agentName: e.agentName });
15
15
  });
16
16
  eventBus.subscribe('message:error', (event) => {
17
17
  const e = event;
18
- this.recordEvent({ type: 'error', timestamp: Date.now(), errorType: e.errorType });
18
+ this.recordEvent({ type: 'error', timestamp: Date.now(), errorType: e.errorType, agentName: e.agentName });
19
19
  });
20
- eventBus.subscribe('message:interrupted', (_event) => {
21
- this.recordEvent({ type: 'interrupted', timestamp: Date.now() });
20
+ eventBus.subscribe('message:interrupted', (event) => {
21
+ const e = event;
22
+ this.recordEvent({ type: 'interrupted', timestamp: Date.now(), agentName: e.agentName });
22
23
  });
23
24
  eventBus.subscribe('tool:result', (event) => {
24
25
  const e = event;
25
26
  if (e.isError) {
26
- this.recordEvent({ type: 'tool-error', timestamp: Date.now(), toolName: e.toolName });
27
+ this.recordEvent({ type: 'tool-error', timestamp: Date.now(), toolName: e.toolName, agentName: e.agentName });
27
28
  }
28
29
  });
29
30
  }
@@ -31,14 +32,18 @@ export class StatsCollector {
31
32
  this.events.push(record);
32
33
  }
33
34
  /**
34
- * 获取统计快照(自动裁剪 >1h 的事件)
35
+ * 获取统计快照。可选 agentName 过滤:未传则全局;传入则只统计该 agent。
36
+ * 自动裁剪 >1h 的事件。
35
37
  */
36
- getSnapshot() {
38
+ getSnapshot(agentName) {
37
39
  const now = Date.now();
38
40
  const cutoff = now - this.HOUR_MS;
39
41
  // 裁剪过期事件
40
42
  this.events = this.events.filter(e => e.timestamp >= cutoff);
41
- // 聚合统计
43
+ // 聚合统计(可按 agent 过滤)
44
+ const filtered = agentName === undefined
45
+ ? this.events
46
+ : this.events.filter(e => (e.agentName ?? '[default]') === agentName);
42
47
  let received = 0;
43
48
  let completed = 0;
44
49
  let errors = 0;
@@ -48,7 +53,7 @@ export class StatsCollector {
48
53
  let interrupts = 0;
49
54
  let totalDuration = 0;
50
55
  let durationCount = 0;
51
- for (const event of this.events) {
56
+ for (const event of filtered) {
52
57
  switch (event.type) {
53
58
  case 'received':
54
59
  received++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolclaw",
3
- "version": "2.8.1",
3
+ "version": "2.8.3",
4
4
  "description": "Lightweight AI Agent gateway connecting Claude Agent SDK to messaging channels (Feishu, ACP) with multi-project session management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",