evolclaw 2.8.0 → 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.
- package/dist/agents/templates.js +122 -0
- package/dist/channels/aun-ops.js +275 -0
- package/dist/channels/aun.js +206 -103
- package/dist/channels/qqbot.js +1 -1
- package/dist/channels/wechat.js +1 -1
- package/dist/cli.js +676 -20
- package/dist/config.js +94 -22
- package/dist/core/agent-registry.js +450 -0
- package/dist/core/command-handler.js +422 -255
- package/dist/core/evolagent-registry.js +503 -0
- package/dist/core/evolagent-schema.js +72 -0
- package/dist/core/evolagent.js +315 -0
- package/dist/core/message/message-bridge.js +23 -3
- package/dist/core/message/message-processor.js +56 -11
- package/dist/core/message/message-queue.js +59 -4
- package/dist/core/reload-hooks.js +87 -0
- package/dist/index.js +119 -20
- package/dist/ipc.js +47 -0
- package/dist/paths.js +2 -0
- package/dist/types.js +2 -0
- package/dist/utils/init-channel.js +91 -221
- package/dist/utils/init.js +18 -42
- package/dist/utils/logger.js +58 -2
- package/dist/utils/reload-hooks.js +87 -0
- package/dist/utils/rich-content-renderer.js +33 -0
- package/dist/utils/stats-collector.js +15 -10
- package/evolclaw-install-aun.md +48 -7
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -155,7 +155,7 @@ export function loadConfig(configPath = resolvePaths().config) {
|
|
|
155
155
|
logger.warn(`Config file missing, creating from sample: ${samplePath}`);
|
|
156
156
|
const sample = JSON.parse(fs.readFileSync(samplePath, 'utf-8'));
|
|
157
157
|
// Set a usable defaultPath
|
|
158
|
-
const defaultProjectDir = path.join(os.homedir(), '
|
|
158
|
+
const defaultProjectDir = path.join(os.homedir(), 'projects', 'default');
|
|
159
159
|
sample.projects.defaultPath = defaultProjectDir;
|
|
160
160
|
if (!fs.existsSync(defaultProjectDir)) {
|
|
161
161
|
fs.mkdirSync(defaultProjectDir, { recursive: true });
|
|
@@ -228,6 +228,45 @@ export function normalizeChannelInstances(cfg, defaultName) {
|
|
|
228
228
|
}
|
|
229
229
|
return [{ ...cfg, name: cfg.name ?? defaultName }];
|
|
230
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Parse a defaultChannel reference. Supports:
|
|
233
|
+
* "feishu" → { type: "feishu" }
|
|
234
|
+
* "feishu/feilun" → { type: "feishu", instance: "feilun" }
|
|
235
|
+
*/
|
|
236
|
+
export function parseDefaultChannelRef(ref) {
|
|
237
|
+
const slash = ref.indexOf('/');
|
|
238
|
+
if (slash < 0)
|
|
239
|
+
return { type: ref };
|
|
240
|
+
return { type: ref.slice(0, slash), instance: ref.slice(slash + 1) };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Validate a defaultChannel reference against a channels config block.
|
|
244
|
+
* Returns an error message string if invalid, or null if OK.
|
|
245
|
+
* - type must be in channelTypes
|
|
246
|
+
* - type must have at least one instance configured
|
|
247
|
+
* - if instance specified, must match an existing instance.name
|
|
248
|
+
* - if instance omitted, type must have exactly 1 instance (else ambiguous)
|
|
249
|
+
*/
|
|
250
|
+
export function validateDefaultChannelRef(ref, channelsBlock) {
|
|
251
|
+
const { type, instance } = parseDefaultChannelRef(ref);
|
|
252
|
+
if (!channelTypes.includes(type)) {
|
|
253
|
+
return `channels.defaultChannel='${ref}' references unknown channel type '${type}'`;
|
|
254
|
+
}
|
|
255
|
+
const instances = normalizeChannelInstances(channelsBlock?.[type], type);
|
|
256
|
+
if (instances.length === 0) {
|
|
257
|
+
return `channels.defaultChannel='${ref}' but channels.${type} has no instances`;
|
|
258
|
+
}
|
|
259
|
+
if (instance) {
|
|
260
|
+
if (!instances.some(i => i.name === instance)) {
|
|
261
|
+
return `channels.defaultChannel='${ref}' but channels.${type} has no instance named '${instance}'`;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
else if (instances.length > 1) {
|
|
265
|
+
const names = instances.map(i => i.name).join(', ');
|
|
266
|
+
return `channels.defaultChannel='${ref}' is ambiguous: channels.${type} has ${instances.length} instances (${names}); use 'type/instanceName' form`;
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
231
270
|
/**
|
|
232
271
|
* Validate that all channel instance names are unique across all channel types.
|
|
233
272
|
* Throws if duplicate names are found.
|
|
@@ -263,10 +302,14 @@ export function getOwner(config, channelOrType) {
|
|
|
263
302
|
}
|
|
264
303
|
return undefined;
|
|
265
304
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
305
|
+
/**
|
|
306
|
+
* Find a channel instance by name in a config-like object and set its owner.
|
|
307
|
+
* Returns true if the instance was found and updated.
|
|
308
|
+
*/
|
|
309
|
+
export function writeOwnerToChannelInstance(root, instanceName, userId) {
|
|
310
|
+
const channels = root?.channels;
|
|
311
|
+
if (!channels || typeof channels !== 'object')
|
|
312
|
+
return false;
|
|
270
313
|
for (const type of channelTypes) {
|
|
271
314
|
const raw = channels[type];
|
|
272
315
|
if (raw === undefined)
|
|
@@ -275,26 +318,37 @@ export function setOwner(config, instanceName, userId, configPath = resolvePaths
|
|
|
275
318
|
const inst = raw.find((item) => item.name === instanceName);
|
|
276
319
|
if (inst) {
|
|
277
320
|
inst.owner = userId;
|
|
278
|
-
|
|
279
|
-
return;
|
|
321
|
+
return true;
|
|
280
322
|
}
|
|
281
323
|
}
|
|
282
324
|
else {
|
|
283
|
-
// Single-object form: match if name matches (or defaults to type name)
|
|
284
325
|
const effectiveName = raw.name ?? type;
|
|
285
326
|
if (effectiveName === instanceName) {
|
|
286
327
|
raw.owner = userId;
|
|
287
|
-
|
|
288
|
-
return;
|
|
328
|
+
return true;
|
|
289
329
|
}
|
|
290
330
|
}
|
|
291
331
|
}
|
|
292
|
-
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
export function setOwner(config, instanceName, userId, configPath = resolvePaths().config) {
|
|
335
|
+
if (!config.channels)
|
|
336
|
+
config.channels = {};
|
|
337
|
+
// 1. Try writing to evolclaw.json (default-agent channels)
|
|
338
|
+
if (writeOwnerToChannelInstance(config, instanceName, userId)) {
|
|
339
|
+
saveConfig(config, configPath);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
// 2. Last resort: if instanceName matches a channel type with no config, create it
|
|
293
343
|
if (channelTypes.includes(instanceName)) {
|
|
294
|
-
channels[instanceName] = { owner: userId };
|
|
344
|
+
config.channels[instanceName] = { owner: userId };
|
|
295
345
|
saveConfig(config, configPath);
|
|
296
346
|
return;
|
|
297
347
|
}
|
|
348
|
+
// 3. I4: No match — warn (don't silently lose owner). Callers managing
|
|
349
|
+
// agent-owned channels should route through EvolAgent.setOwner before
|
|
350
|
+
// falling back to this global setter.
|
|
351
|
+
logger.warn(`[setOwner] Channel instance "${instanceName}" not found in evolclaw.json. Owner ${userId} not persisted.`);
|
|
298
352
|
}
|
|
299
353
|
export function getChannelShowActivities(config, instanceName) {
|
|
300
354
|
for (const type of channelTypes) {
|
|
@@ -353,10 +407,10 @@ export function getDefaultSessionMode(config, chatType) {
|
|
|
353
407
|
return cm.private;
|
|
354
408
|
}
|
|
355
409
|
export function isOwner(config, channelOrType, userId) {
|
|
356
|
-
//
|
|
410
|
+
// 按实例名精确匹配(evolclaw.json)
|
|
357
411
|
if (getOwner(config, channelOrType) === userId)
|
|
358
412
|
return true;
|
|
359
|
-
// 按 channelType
|
|
413
|
+
// 按 channelType 匹配:检查该类型下所有实例(evolclaw.json)
|
|
360
414
|
for (const type of channelTypes) {
|
|
361
415
|
if (type !== channelOrType)
|
|
362
416
|
continue;
|
|
@@ -483,17 +537,35 @@ export function validateConfigIntegrity(config) {
|
|
|
483
537
|
reasons.push(`agents.defaultAgent='${defaultAgent}' but agents.${defaultAgent} does not exist`);
|
|
484
538
|
}
|
|
485
539
|
}
|
|
486
|
-
// channels —
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
540
|
+
// channels — 单实例自动推断,多实例必填 defaultChannel
|
|
541
|
+
// 支持两种形式:
|
|
542
|
+
// "feishu" → type 级,要求该 type 下只有 1 个实例
|
|
543
|
+
// "feishu/feilun" → type/instanceName,精确指向实例
|
|
544
|
+
const totalInstances = channelTypes.reduce((acc, t) => {
|
|
545
|
+
return acc + normalizeChannelInstances(config.channels?.[t], t).length;
|
|
546
|
+
}, 0);
|
|
547
|
+
if (totalInstances === 0) {
|
|
548
|
+
reasons.push('Missing channels: no channel instances configured');
|
|
549
|
+
}
|
|
550
|
+
else if (totalInstances === 1) {
|
|
551
|
+
// 单实例:defaultChannel 可省略(自动推断)
|
|
552
|
+
const dc = config.channels?.defaultChannel;
|
|
553
|
+
if (dc) {
|
|
554
|
+
const err = validateDefaultChannelRef(dc, config.channels);
|
|
555
|
+
if (err)
|
|
556
|
+
reasons.push(err);
|
|
492
557
|
}
|
|
493
558
|
}
|
|
494
559
|
else {
|
|
495
|
-
|
|
496
|
-
|
|
560
|
+
// 多实例:defaultChannel 必填
|
|
561
|
+
const dc = config.channels?.defaultChannel;
|
|
562
|
+
if (!dc) {
|
|
563
|
+
reasons.push('Missing channels.defaultChannel (multiple channel instances configured; must specify "type" or "type/instanceName")');
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
const err = validateDefaultChannelRef(dc, config.channels);
|
|
567
|
+
if (err)
|
|
568
|
+
reasons.push(err);
|
|
497
569
|
}
|
|
498
570
|
}
|
|
499
571
|
// projects
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { EvolAgent } from './evolagent.js';
|
|
4
|
+
import { validateEvolAgentConfig } from './evolagent-schema.js';
|
|
5
|
+
import { extractFingerprint } from '../utils/channel-fingerprint.js';
|
|
6
|
+
import { logger } from '../utils/logger.js';
|
|
7
|
+
export class AgentRegistry {
|
|
8
|
+
agentsDir;
|
|
9
|
+
agents = new Map();
|
|
10
|
+
defaultAgent = null;
|
|
11
|
+
channelIndex = new Map();
|
|
12
|
+
globalWriter;
|
|
13
|
+
constructor(agentsDir, globalWriter) {
|
|
14
|
+
this.agentsDir = agentsDir;
|
|
15
|
+
this.globalWriter = globalWriter;
|
|
16
|
+
}
|
|
17
|
+
/** Late-binding setter for tests / index.ts wiring order. */
|
|
18
|
+
setGlobalWriter(writer) {
|
|
19
|
+
this.globalWriter = writer;
|
|
20
|
+
}
|
|
21
|
+
loadAll(globalConfig) {
|
|
22
|
+
this.agents.clear();
|
|
23
|
+
this.channelIndex.clear();
|
|
24
|
+
const files = fs.existsSync(this.agentsDir)
|
|
25
|
+
? fs.readdirSync(this.agentsDir).filter(f => f.endsWith('.json'))
|
|
26
|
+
: [];
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
const fullPath = path.join(this.agentsDir, file);
|
|
29
|
+
try {
|
|
30
|
+
const raw = JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
|
|
31
|
+
const validation = validateEvolAgentConfig(raw);
|
|
32
|
+
if (!validation.valid) {
|
|
33
|
+
const name = raw?.name || path.basename(file, '.json');
|
|
34
|
+
const errorAgent = new EvolAgent(fullPath, { ...raw, name });
|
|
35
|
+
errorAgent.status = 'error';
|
|
36
|
+
errorAgent.error = validation.errors.join('; ');
|
|
37
|
+
this.agents.set(name, errorAgent);
|
|
38
|
+
logger.warn(`[AgentRegistry] ${file}: ${validation.errors.join('; ')}`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const agent = new EvolAgent(fullPath, raw);
|
|
42
|
+
this.agents.set(agent.name, agent);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
logger.warn(`[AgentRegistry] Failed to load ${file}: ${e}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
this.defaultAgent = this.buildDefaultAgent(globalConfig);
|
|
49
|
+
this.detectAndFlagConflicts();
|
|
50
|
+
this.buildChannelIndex();
|
|
51
|
+
}
|
|
52
|
+
buildDefaultAgent(globalConfig) {
|
|
53
|
+
const agents = globalConfig.agents || {};
|
|
54
|
+
const defaultName = agents.defaultAgent || 'claude';
|
|
55
|
+
const cfg = {
|
|
56
|
+
name: '[default]',
|
|
57
|
+
enabled: true,
|
|
58
|
+
agents: { [defaultName]: agents[defaultName] || {} },
|
|
59
|
+
channels: globalConfig.channels || {},
|
|
60
|
+
projects: { defaultPath: globalConfig.projects?.defaultPath || process.cwd() },
|
|
61
|
+
chatmode: globalConfig.chatmode,
|
|
62
|
+
};
|
|
63
|
+
return new EvolAgent(null, cfg, { isDefault: true });
|
|
64
|
+
}
|
|
65
|
+
detectAndFlagConflicts() {
|
|
66
|
+
const seen = new Map();
|
|
67
|
+
const record = (agentName, channelsBlock) => {
|
|
68
|
+
for (const [type, raw] of Object.entries(channelsBlock || {})) {
|
|
69
|
+
if (type === 'defaultChannel')
|
|
70
|
+
continue;
|
|
71
|
+
const instances = Array.isArray(raw) ? raw : [raw];
|
|
72
|
+
for (const inst of instances) {
|
|
73
|
+
if (!inst || typeof inst !== 'object')
|
|
74
|
+
continue;
|
|
75
|
+
const fp = extractFingerprint(type, inst);
|
|
76
|
+
if (!fp)
|
|
77
|
+
continue;
|
|
78
|
+
const instName = inst.name ?? type;
|
|
79
|
+
const entry = seen.get(fp) || [];
|
|
80
|
+
entry.push({ agent: agentName, instance: instName });
|
|
81
|
+
seen.set(fp, entry);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
for (const agent of this.agents.values()) {
|
|
86
|
+
if (agent.status === 'error')
|
|
87
|
+
continue;
|
|
88
|
+
record(agent.name, agent.config.channels);
|
|
89
|
+
}
|
|
90
|
+
if (this.defaultAgent) {
|
|
91
|
+
record(this.defaultAgent.name, this.defaultAgent.config.channels);
|
|
92
|
+
}
|
|
93
|
+
for (const [_fp, occurrences] of seen) {
|
|
94
|
+
if (occurrences.length <= 1)
|
|
95
|
+
continue;
|
|
96
|
+
const msg = `Channel conflict: fingerprint claimed by ${occurrences.map(o => `${o.agent}(${o.instance})`).join(', ')}`;
|
|
97
|
+
const involvedNames = [...new Set(occurrences.map(o => o.agent))];
|
|
98
|
+
for (const name of involvedNames) {
|
|
99
|
+
if (name === '[default]')
|
|
100
|
+
continue;
|
|
101
|
+
const a = this.agents.get(name);
|
|
102
|
+
if (a && a.status !== 'error') {
|
|
103
|
+
a.status = 'error';
|
|
104
|
+
a.error = msg;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
logger.error(`[AgentRegistry] ${msg}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
buildChannelIndex() {
|
|
111
|
+
for (const agent of this.agents.values()) {
|
|
112
|
+
if (agent.status === 'error' || agent.status === 'disabled')
|
|
113
|
+
continue;
|
|
114
|
+
for (const name of agent.channelInstanceNames()) {
|
|
115
|
+
this.channelIndex.set(name, agent.name);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (this.defaultAgent) {
|
|
119
|
+
for (const name of this.defaultAgent.channelInstanceNames()) {
|
|
120
|
+
if (this.channelIndex.has(name))
|
|
121
|
+
continue;
|
|
122
|
+
this.channelIndex.set(name, '[default]');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
resolveByChannel(channelName) {
|
|
127
|
+
const agentName = this.channelIndex.get(channelName);
|
|
128
|
+
if (!agentName)
|
|
129
|
+
return null;
|
|
130
|
+
if (agentName === '[default]')
|
|
131
|
+
return this.defaultAgent;
|
|
132
|
+
return this.agents.get(agentName) || null;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Check ownership of a channel via the agent that owns it.
|
|
136
|
+
* - For named EvolAgent: reads agent.config (memory; reflects agent.json).
|
|
137
|
+
* - For DefaultAgent or unknown channel: invokes `globalFallback`, which
|
|
138
|
+
* should consult the global config (evolclaw.json) via `config.ts:isOwner`.
|
|
139
|
+
*/
|
|
140
|
+
isOwner(channelName, userId, globalFallback) {
|
|
141
|
+
const agent = this.resolveByChannel(channelName);
|
|
142
|
+
if (agent && !agent.isDefault)
|
|
143
|
+
return agent.isOwner(channelName, userId);
|
|
144
|
+
return globalFallback(channelName, userId);
|
|
145
|
+
}
|
|
146
|
+
/** Same routing logic as `isOwner`, applied to admin checks. */
|
|
147
|
+
isAdmin(channelName, userId, globalFallback) {
|
|
148
|
+
const agent = this.resolveByChannel(channelName);
|
|
149
|
+
if (agent && !agent.isDefault)
|
|
150
|
+
return agent.isAdmin(channelName, userId);
|
|
151
|
+
return globalFallback(channelName, userId);
|
|
152
|
+
}
|
|
153
|
+
/** Lookup current owner — agent first, then DefaultAgent (which mirrors evolclaw.json). */
|
|
154
|
+
getOwner(channelName) {
|
|
155
|
+
const agent = this.resolveByChannel(channelName);
|
|
156
|
+
if (!agent)
|
|
157
|
+
return undefined;
|
|
158
|
+
return agent.getOwner(channelName);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Persist owner. Routes to agent.json for named agents, or to evolclaw.json
|
|
162
|
+
* via the configured `globalWriter` for DefaultAgent. No-ops with a warning
|
|
163
|
+
* when the channel is unknown or no global writer is wired.
|
|
164
|
+
*/
|
|
165
|
+
setChannelOwner(channelName, userId) {
|
|
166
|
+
const agent = this.resolveByChannel(channelName);
|
|
167
|
+
if (!agent) {
|
|
168
|
+
logger.warn(`[AgentRegistry] setChannelOwner: channel "${channelName}" not found`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (agent.isDefault) {
|
|
172
|
+
if (!this.globalWriter) {
|
|
173
|
+
logger.warn(`[AgentRegistry] setChannelOwner: no globalWriter wired for default channel "${channelName}"`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
this.globalWriter.setOwner(channelName, userId);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
agent.setOwner(channelName, userId);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Read showActivities mode. Falls back to `'all'` when the channel is
|
|
184
|
+
* unknown — matches the prior behavior of `config.ts:getChannelShowActivities`.
|
|
185
|
+
*/
|
|
186
|
+
getShowActivities(channelName) {
|
|
187
|
+
const agent = this.resolveByChannel(channelName);
|
|
188
|
+
if (!agent)
|
|
189
|
+
return 'all';
|
|
190
|
+
return agent.getShowActivities(channelName);
|
|
191
|
+
}
|
|
192
|
+
/** Persist showActivities. Routes to agent.json or evolclaw.json. */
|
|
193
|
+
setShowActivities(channelName, mode) {
|
|
194
|
+
const agent = this.resolveByChannel(channelName);
|
|
195
|
+
if (!agent) {
|
|
196
|
+
logger.warn(`[AgentRegistry] setShowActivities: channel "${channelName}" not found`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (agent.isDefault) {
|
|
200
|
+
if (!this.globalWriter?.setShowActivities) {
|
|
201
|
+
logger.warn(`[AgentRegistry] setShowActivities: no globalWriter wired for default channel "${channelName}"`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
this.globalWriter.setShowActivities(channelName, mode);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
agent.setShowActivities(channelName, mode);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
get(name) {
|
|
211
|
+
if (name === '[default]')
|
|
212
|
+
return this.defaultAgent;
|
|
213
|
+
return this.agents.get(name) || null;
|
|
214
|
+
}
|
|
215
|
+
list() {
|
|
216
|
+
const result = [];
|
|
217
|
+
for (const agent of this.agents.values()) {
|
|
218
|
+
result.push(this.toInfo(agent));
|
|
219
|
+
}
|
|
220
|
+
if (this.defaultAgent) {
|
|
221
|
+
result.push(this.toInfo(this.defaultAgent));
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
runnableAgents() {
|
|
226
|
+
return [...this.agents.values()].filter(a => a.status === 'stopped');
|
|
227
|
+
}
|
|
228
|
+
async reload(name, hooks) {
|
|
229
|
+
const oldAgent = this.agents.get(name);
|
|
230
|
+
if (!oldAgent)
|
|
231
|
+
throw new Error(`Agent "${name}" not found`);
|
|
232
|
+
if (!oldAgent.configPath)
|
|
233
|
+
throw new Error(`Cannot reload DefaultAgent`);
|
|
234
|
+
// 1. Re-read config from disk
|
|
235
|
+
const raw = JSON.parse(fs.readFileSync(oldAgent.configPath, 'utf-8'));
|
|
236
|
+
const validation = validateEvolAgentConfig(raw);
|
|
237
|
+
if (!validation.valid) {
|
|
238
|
+
throw new Error(`Invalid config after edit: ${validation.errors.join('; ')}`);
|
|
239
|
+
}
|
|
240
|
+
const newAgent = new EvolAgent(oldAgent.configPath, raw);
|
|
241
|
+
// Warn if projectPath changed — existing sessions retain old path (by design,
|
|
242
|
+
// to avoid breaking SDK conversation history at .claude/<encoded-path>/...)
|
|
243
|
+
if (oldAgent.projectPath !== newAgent.projectPath) {
|
|
244
|
+
logger.warn(`[AgentRegistry] Agent "${name}" projectPath changed: ${oldAgent.projectPath} → ${newAgent.projectPath}. ` +
|
|
245
|
+
`Existing sessions retain the old path; only new sessions will use the new path. ` +
|
|
246
|
+
`To migrate, manually UPDATE sessions SET project_path=? WHERE id=? (warning: SDK conversation history may be lost).`);
|
|
247
|
+
}
|
|
248
|
+
// 2. Fingerprint conflict check (against all others except self)
|
|
249
|
+
const conflict = this.checkConflictForReload(newAgent, name);
|
|
250
|
+
if (conflict) {
|
|
251
|
+
throw new Error(`Channel conflict: ${conflict}`);
|
|
252
|
+
}
|
|
253
|
+
// 3. Compute channel diff
|
|
254
|
+
const oldChannels = new Set(oldAgent.channelInstanceNames());
|
|
255
|
+
const newChannels = new Set(newAgent.channelInstanceNames());
|
|
256
|
+
const toRemove = [...oldChannels].filter(c => !newChannels.has(c));
|
|
257
|
+
const toAdd = [...newChannels].filter(c => !oldChannels.has(c));
|
|
258
|
+
const kept = [...oldChannels].filter(c => newChannels.has(c));
|
|
259
|
+
// I6: detect kept-channel credential changes — treat as remove+add so
|
|
260
|
+
// the channel reconnects with new credentials (e.g. appSecret rotated).
|
|
261
|
+
const credentialsChanged = [];
|
|
262
|
+
const trulyKept = [];
|
|
263
|
+
for (const ch of kept) {
|
|
264
|
+
const oldCh = getChannelInstanceConfig(oldAgent, ch);
|
|
265
|
+
const newCh = getChannelInstanceConfig(newAgent, ch);
|
|
266
|
+
if (oldCh && newCh && channelConfigChanged(oldCh.config, newCh.config)) {
|
|
267
|
+
credentialsChanged.push(ch);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
trulyKept.push(ch);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
toRemove.push(...credentialsChanged);
|
|
274
|
+
toAdd.push(...credentialsChanged);
|
|
275
|
+
// Track what was removed/added so we can roll back on failure
|
|
276
|
+
const removedSuccessfully = [];
|
|
277
|
+
const addedSuccessfully = [];
|
|
278
|
+
try {
|
|
279
|
+
// 4. Drain channels being removed
|
|
280
|
+
for (const ch of toRemove) {
|
|
281
|
+
await hooks.drainChannel(ch);
|
|
282
|
+
}
|
|
283
|
+
// 5. Disconnect removed channels
|
|
284
|
+
for (const ch of toRemove) {
|
|
285
|
+
await hooks.disconnectChannel(ch);
|
|
286
|
+
removedSuccessfully.push(ch);
|
|
287
|
+
}
|
|
288
|
+
// 6. Start new channels
|
|
289
|
+
for (const ch of toAdd) {
|
|
290
|
+
await hooks.startChannel(newAgent, ch);
|
|
291
|
+
addedSuccessfully.push(ch);
|
|
292
|
+
}
|
|
293
|
+
// 7. Transfer kept channel adapters from old to new (only truly unchanged ones)
|
|
294
|
+
for (const ch of trulyKept) {
|
|
295
|
+
const adapter = oldAgent.channels.get(ch);
|
|
296
|
+
if (adapter)
|
|
297
|
+
newAgent.channels.set(ch, adapter);
|
|
298
|
+
}
|
|
299
|
+
// 8. Preserve runtime state
|
|
300
|
+
// I5: only set 'running' when oldAgent was running; preserve error/disabled
|
|
301
|
+
newAgent.activeSessions = oldAgent.activeSessions;
|
|
302
|
+
newAgent.lastActivity = oldAgent.lastActivity;
|
|
303
|
+
if (oldAgent.status === 'error' || oldAgent.status === 'disabled') {
|
|
304
|
+
newAgent.status = oldAgent.status;
|
|
305
|
+
newAgent.error = oldAgent.error;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
newAgent.status = 'running';
|
|
309
|
+
}
|
|
310
|
+
// 9. Swap in registry
|
|
311
|
+
this.agents.set(name, newAgent);
|
|
312
|
+
// 10. Rebuild channel index
|
|
313
|
+
this.channelIndex.clear();
|
|
314
|
+
this.buildChannelIndex();
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
// C1: Rollback — restore original channels, keep oldAgent in registry
|
|
318
|
+
logger.error(`[Reload] Failed: ${err}. Attempting rollback for "${name}".`);
|
|
319
|
+
for (const ch of addedSuccessfully) {
|
|
320
|
+
try {
|
|
321
|
+
await hooks.disconnectChannel(ch);
|
|
322
|
+
}
|
|
323
|
+
catch (_) { /* best effort */ }
|
|
324
|
+
}
|
|
325
|
+
for (const ch of removedSuccessfully) {
|
|
326
|
+
try {
|
|
327
|
+
await hooks.startChannel(oldAgent, ch);
|
|
328
|
+
}
|
|
329
|
+
catch (_) { /* best effort */ }
|
|
330
|
+
}
|
|
331
|
+
// Don't swap registry — oldAgent stays in place
|
|
332
|
+
oldAgent.status = 'error';
|
|
333
|
+
oldAgent.error = `Reload failed (rollback attempted): ${err instanceof Error ? err.message : String(err)}`;
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
checkConflictForReload(newAgent, excludeName) {
|
|
338
|
+
const newFingerprints = new Map(); // fp → instanceName
|
|
339
|
+
for (const [type, raw] of Object.entries(newAgent.config.channels || {})) {
|
|
340
|
+
if (type === 'defaultChannel')
|
|
341
|
+
continue;
|
|
342
|
+
const instances = Array.isArray(raw) ? raw : [raw];
|
|
343
|
+
for (const inst of instances) {
|
|
344
|
+
if (!inst || typeof inst !== 'object')
|
|
345
|
+
continue;
|
|
346
|
+
const fp = extractFingerprint(type, inst);
|
|
347
|
+
if (!fp)
|
|
348
|
+
continue;
|
|
349
|
+
const instName = inst.name ?? type;
|
|
350
|
+
newFingerprints.set(fp, instName);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// Check against all other agents (excluding self)
|
|
354
|
+
for (const [agentName, agent] of this.agents) {
|
|
355
|
+
if (agentName === excludeName)
|
|
356
|
+
continue;
|
|
357
|
+
if (agent.status === 'error' || agent.status === 'disabled')
|
|
358
|
+
continue;
|
|
359
|
+
for (const [type, raw] of Object.entries(agent.config.channels || {})) {
|
|
360
|
+
if (type === 'defaultChannel')
|
|
361
|
+
continue;
|
|
362
|
+
const instances = Array.isArray(raw) ? raw : [raw];
|
|
363
|
+
for (const inst of instances) {
|
|
364
|
+
if (!inst || typeof inst !== 'object')
|
|
365
|
+
continue;
|
|
366
|
+
const fp = extractFingerprint(type, inst);
|
|
367
|
+
if (!fp)
|
|
368
|
+
continue;
|
|
369
|
+
if (newFingerprints.has(fp)) {
|
|
370
|
+
return `${fp} conflicts with agent "${agentName}"`;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Check against DefaultAgent
|
|
376
|
+
if (this.defaultAgent) {
|
|
377
|
+
for (const [type, raw] of Object.entries(this.defaultAgent.config.channels || {})) {
|
|
378
|
+
if (type === 'defaultChannel')
|
|
379
|
+
continue;
|
|
380
|
+
const instances = Array.isArray(raw) ? raw : [raw];
|
|
381
|
+
for (const inst of instances) {
|
|
382
|
+
if (!inst || typeof inst !== 'object')
|
|
383
|
+
continue;
|
|
384
|
+
const fp = extractFingerprint(type, inst);
|
|
385
|
+
if (!fp)
|
|
386
|
+
continue;
|
|
387
|
+
if (newFingerprints.has(fp)) {
|
|
388
|
+
return `${fp} conflicts with DefaultAgent`;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
toInfo(agent) {
|
|
396
|
+
let baseagent = 'claude';
|
|
397
|
+
let model;
|
|
398
|
+
let effort;
|
|
399
|
+
try {
|
|
400
|
+
baseagent = agent.baseagent;
|
|
401
|
+
model = agent.model;
|
|
402
|
+
effort = agent.effort;
|
|
403
|
+
}
|
|
404
|
+
catch { /* invalid config */ }
|
|
405
|
+
return {
|
|
406
|
+
name: agent.name,
|
|
407
|
+
status: agent.status,
|
|
408
|
+
channels: agent.channelInstanceNames(),
|
|
409
|
+
projectPath: agent.config.projects?.defaultPath ?? '',
|
|
410
|
+
baseagent,
|
|
411
|
+
model,
|
|
412
|
+
effort,
|
|
413
|
+
lastActivity: agent.lastActivity,
|
|
414
|
+
activeSessions: agent.activeSessions,
|
|
415
|
+
error: agent.error,
|
|
416
|
+
isDefault: agent.isDefault,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Locate the raw config of a channel instance by name within an agent config.
|
|
422
|
+
* Returns `{ type, config }` or null if not found.
|
|
423
|
+
*
|
|
424
|
+
* Matches against the effective channel name (with agent prefix for EvolAgents),
|
|
425
|
+
* mirroring `EvolAgent.findChannelInstance`. Only used by `reload()` to detect
|
|
426
|
+
* kept-channel credential changes.
|
|
427
|
+
*/
|
|
428
|
+
function getChannelInstanceConfig(agent, channelName) {
|
|
429
|
+
for (const [type, raw] of Object.entries(agent.config?.channels || {})) {
|
|
430
|
+
if (type === 'defaultChannel')
|
|
431
|
+
continue;
|
|
432
|
+
const instances = Array.isArray(raw) ? raw : [raw];
|
|
433
|
+
for (const inst of instances) {
|
|
434
|
+
if (!inst || typeof inst !== 'object')
|
|
435
|
+
continue;
|
|
436
|
+
const effName = agent.effectiveChannelName(type, inst.name);
|
|
437
|
+
if (effName === channelName)
|
|
438
|
+
return { type, config: inst };
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Compare two channel-instance configs by serialized JSON. Channel configs
|
|
445
|
+
* are plain JSON-shaped objects (no functions/Buffers), so this is a sound
|
|
446
|
+
* structural compare for "did anything in this channel block change".
|
|
447
|
+
*/
|
|
448
|
+
function channelConfigChanged(oldConfig, newConfig) {
|
|
449
|
+
return JSON.stringify(oldConfig) !== JSON.stringify(newConfig);
|
|
450
|
+
}
|