bus-agent 2.3.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.
- package/.env.coco +11 -0
- package/AGENTS.md +37 -0
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/SKILL.md +314 -0
- package/backup.js +57 -0
- package/bin/cli.js +41 -0
- package/bridge.js +325 -0
- package/claude-mcp.json +10 -0
- package/clients/coco-client.ts +245 -0
- package/clients/coco_client.py +216 -0
- package/coco-aliases.sh +10 -0
- package/coco-cli.js +1002 -0
- package/coco-tool.js +177 -0
- package/coco.js +26 -0
- package/cursor-mcp.json +3 -0
- package/doctor.js +24 -0
- package/hermes-forwarder.js +152 -0
- package/hermes.example.json +9 -0
- package/index.js +52 -0
- package/lib/backup.js +256 -0
- package/lib/bus.js +516 -0
- package/lib/daemon.js +96 -0
- package/lib/doctor.js +333 -0
- package/lib/hermes.js +162 -0
- package/lib/mcp.js +730 -0
- package/lib/memory.js +667 -0
- package/lib/orchestrator.js +426 -0
- package/lib/scheduler.js +259 -0
- package/lib/tunnel.js +317 -0
- package/mcporter.example.json +14 -0
- package/opencode-mcp.json +10 -0
- package/package.json +76 -0
- package/scripts/install.bat +5 -0
- package/scripts/install.ps1 +100 -0
- package/setup.js +320 -0
- package/tunnel.js +66 -0
- package/webhook-gateway.js +420 -0
package/lib/bus.js
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Bus — Universal MCP Agent Communication Hub v2.1
|
|
3
|
+
*
|
|
4
|
+
* Any MCP-compatible agent connects via mcporter and can:
|
|
5
|
+
* - Register with rich profile (model, capabilities, status)
|
|
6
|
+
* - Discover agents by capability/model/tags
|
|
7
|
+
* - Send/receive messages (DM, broadcast, channels)
|
|
8
|
+
* - Long-poll for new messages
|
|
9
|
+
* - Get notified when agents join/leave the bus
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { EventEmitter } = require('events');
|
|
14
|
+
|
|
15
|
+
const DATA_DIR = path.join(__dirname, '..', '.bus');
|
|
16
|
+
const AGENTS_FILE = path.join(DATA_DIR, 'agents.json');
|
|
17
|
+
const MSGS_DIR = path.join(DATA_DIR, 'messages');
|
|
18
|
+
const CHANNELS_DIR = path.join(DATA_DIR, 'channels');
|
|
19
|
+
const EVENTS_DIR = path.join(DATA_DIR, 'events');
|
|
20
|
+
|
|
21
|
+
const OFFLINE_TIMEOUT_MS = 300000; // 5 min
|
|
22
|
+
|
|
23
|
+
class AgentBus extends EventEmitter {
|
|
24
|
+
constructor() {
|
|
25
|
+
super();
|
|
26
|
+
for (const d of [DATA_DIR, MSGS_DIR, CHANNELS_DIR, EVENTS_DIR]) {
|
|
27
|
+
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
this._agents = this._loadJson(AGENTS_FILE, {});
|
|
30
|
+
this._waiters = {};
|
|
31
|
+
this._eventWaiters = {};
|
|
32
|
+
this._checkStaleAgents();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Profile Schema ────────────────────────────────────
|
|
36
|
+
//
|
|
37
|
+
// Each agent profile includes:
|
|
38
|
+
// name, description,
|
|
39
|
+
// model: { provider, name, context_window },
|
|
40
|
+
// capabilities: [string], // e.g. ["code-review", "web-search", "chat", "filesystem"]
|
|
41
|
+
// tags: [string], // e.g. ["code", "ai", "bot"]
|
|
42
|
+
// status: "idle"|"busy"|"offline",
|
|
43
|
+
// version: string,
|
|
44
|
+
// endpoints: { mcp?, http?, websocket? },
|
|
45
|
+
// tools: [string], // tools this agent exposes
|
|
46
|
+
// last_seen, registered_at
|
|
47
|
+
|
|
48
|
+
registerAgent(name, description = '', metadata = {}) {
|
|
49
|
+
const existing = this._agents[name];
|
|
50
|
+
const profile = {
|
|
51
|
+
name,
|
|
52
|
+
description,
|
|
53
|
+
model: metadata.model || existing?.model || null,
|
|
54
|
+
capabilities: metadata.capabilities || existing?.capabilities || [],
|
|
55
|
+
tags: metadata.tags || existing?.tags || [],
|
|
56
|
+
status: metadata.status || 'idle',
|
|
57
|
+
version: metadata.version || existing?.version || '1.0.0',
|
|
58
|
+
endpoints: metadata.endpoints || existing?.endpoints || {},
|
|
59
|
+
tools: metadata.tools || existing?.tools || [],
|
|
60
|
+
metadata: metadata.metadata || existing?.metadata || {},
|
|
61
|
+
last_seen: new Date().toISOString(),
|
|
62
|
+
registered_at: existing?.registered_at || new Date().toISOString(),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const isNew = !existing;
|
|
66
|
+
this._agents[name] = profile;
|
|
67
|
+
this._saveJson(AGENTS_FILE, this._agents);
|
|
68
|
+
|
|
69
|
+
// ── Discovery Broadcast ──
|
|
70
|
+
if (isNew) {
|
|
71
|
+
this._logEvent('system', 'agent_joined', { agent: name, profile });
|
|
72
|
+
this._broadcastSystemMessage(`system:agent_joined`, name, {
|
|
73
|
+
agent: name,
|
|
74
|
+
description,
|
|
75
|
+
capabilities: metadata.capabilities || [],
|
|
76
|
+
timestamp: profile.registered_at,
|
|
77
|
+
});
|
|
78
|
+
// Send welcome DM to the new agent
|
|
79
|
+
this.sendMessage('coco', name,
|
|
80
|
+
`👋 Welcome to CoCo Bus, **${name}**!\n` +
|
|
81
|
+
`You are now connected to ${Object.keys(this._agents).length} agent(s).\n` +
|
|
82
|
+
`Try: agent_list, whoami, or send a message to another agent.`,
|
|
83
|
+
{ type: 'system_welcome' }
|
|
84
|
+
);
|
|
85
|
+
this._logSystemWelcome(name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { registered: true, is_new: isNew, agent: profile };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
updateProfile(name, updates) {
|
|
92
|
+
if (!this._agents[name]) return { ok: false, error: 'Agent not registered' };
|
|
93
|
+
|
|
94
|
+
const profile = this._agents[name];
|
|
95
|
+
const allowed = ['description', 'model', 'capabilities', 'tags', 'status', 'version', 'endpoints', 'tools', 'metadata'];
|
|
96
|
+
for (const key of allowed) {
|
|
97
|
+
if (updates[key] !== undefined) {
|
|
98
|
+
profile[key] = updates[key];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
profile.last_seen = new Date().toISOString();
|
|
102
|
+
this._saveJson(AGENTS_FILE, this._agents);
|
|
103
|
+
this._logEvent('system', 'profile_updated', { agent: name, changes: Object.keys(updates) });
|
|
104
|
+
return { ok: true, profile };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
getProfile(name) {
|
|
108
|
+
return this._agents[name] || null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
listAgents(filter = {}) {
|
|
112
|
+
let agents = Object.values(this._agents);
|
|
113
|
+
|
|
114
|
+
if (filter.online_only) {
|
|
115
|
+
const cutoff = Date.now() - OFFLINE_TIMEOUT_MS;
|
|
116
|
+
agents = agents.filter(a => new Date(a.last_seen).getTime() > cutoff);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (filter.capability) {
|
|
120
|
+
const cap = filter.capability.toLowerCase();
|
|
121
|
+
agents = agents.filter(a => (a.capabilities || []).some(c => c.toLowerCase().includes(cap)));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (filter.model) {
|
|
125
|
+
const m = filter.model.toLowerCase();
|
|
126
|
+
agents = agents.filter(a => {
|
|
127
|
+
const mdl = a.model?.name || a.model?.provider || '';
|
|
128
|
+
return mdl.toLowerCase().includes(m);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (filter.tag) {
|
|
133
|
+
const tag = filter.tag.toLowerCase();
|
|
134
|
+
agents = agents.filter(a => (a.tags || []).some(t => t.toLowerCase() === tag));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (filter.status) {
|
|
138
|
+
agents = agents.filter(a => a.status === filter.status);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (filter.search) {
|
|
142
|
+
const q = filter.search.toLowerCase();
|
|
143
|
+
agents = agents.filter(a =>
|
|
144
|
+
a.name.toLowerCase().includes(q) ||
|
|
145
|
+
(a.description || '').toLowerCase().includes(q) ||
|
|
146
|
+
(a.capabilities || []).some(c => c.toLowerCase().includes(q)) ||
|
|
147
|
+
(a.tags || []).some(t => t.toLowerCase().includes(q))
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return agents;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
getAgent(name) {
|
|
155
|
+
return this._agents[name] || null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
heartbeat(name) {
|
|
159
|
+
if (this._agents[name]) {
|
|
160
|
+
const wasOffline = this._agents[name].status === 'offline' ||
|
|
161
|
+
(Date.now() - new Date(this._agents[name].last_seen).getTime()) > OFFLINE_TIMEOUT_MS;
|
|
162
|
+
this._agents[name].last_seen = new Date().toISOString();
|
|
163
|
+
this._agents[name].status = 'idle';
|
|
164
|
+
this._saveJson(AGENTS_FILE, this._agents);
|
|
165
|
+
|
|
166
|
+
if (wasOffline) {
|
|
167
|
+
this._logEvent('system', 'agent_online', { agent: name });
|
|
168
|
+
}
|
|
169
|
+
return { ok: true, online: true };
|
|
170
|
+
}
|
|
171
|
+
return { ok: false, error: 'Agent not registered' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
setStatus(name, status) {
|
|
175
|
+
const valid = ['idle', 'busy', 'offline'];
|
|
176
|
+
if (!valid.includes(status)) return { ok: false, error: `Status must be: ${valid.join(', ')}` };
|
|
177
|
+
if (!this._agents[name]) return { ok: false, error: 'Agent not registered' };
|
|
178
|
+
|
|
179
|
+
this._agents[name].status = status;
|
|
180
|
+
this._agents[name].last_seen = new Date().toISOString();
|
|
181
|
+
this._saveJson(AGENTS_FILE, this._agents);
|
|
182
|
+
this._logEvent('system', 'status_change', { agent: name, status });
|
|
183
|
+
return { ok: true, status };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Direct Messages ─────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
sendMessage(from, to, message, metadata = {}) {
|
|
189
|
+
const msg = {
|
|
190
|
+
id: this._genId(),
|
|
191
|
+
from,
|
|
192
|
+
to,
|
|
193
|
+
message,
|
|
194
|
+
metadata,
|
|
195
|
+
timestamp: new Date().toISOString(),
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const inboxDir = path.join(MSGS_DIR, to);
|
|
199
|
+
if (!fs.existsSync(inboxDir)) fs.mkdirSync(inboxDir, { recursive: true });
|
|
200
|
+
fs.writeFileSync(path.join(inboxDir, `${msg.id}.json`), JSON.stringify(msg, null, 2), 'utf-8');
|
|
201
|
+
|
|
202
|
+
const outboxDir = path.join(MSGS_DIR, `${from}_outbox`);
|
|
203
|
+
if (!fs.existsSync(outboxDir)) fs.mkdirSync(outboxDir, { recursive: true });
|
|
204
|
+
fs.writeFileSync(path.join(outboxDir, `${msg.id}.json`), JSON.stringify(msg, null, 2), 'utf-8');
|
|
205
|
+
|
|
206
|
+
this._wakeWaiters(to, msg);
|
|
207
|
+
|
|
208
|
+
return { sent: true, message_id: msg.id };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
broadcastMessage(from, message) {
|
|
212
|
+
const results = [];
|
|
213
|
+
for (const agentName of Object.keys(this._agents)) {
|
|
214
|
+
if (agentName !== from) {
|
|
215
|
+
const r = this.sendMessage(from, agentName, message, { broadcast: true });
|
|
216
|
+
results.push({ to: agentName, message_id: r.message_id });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { broadcast: true, targets: results.length, results };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
fetchMessages(agentName, limit = 50, opts = {}) {
|
|
223
|
+
const inboxDir = path.join(MSGS_DIR, agentName);
|
|
224
|
+
if (!fs.existsSync(inboxDir)) return [];
|
|
225
|
+
|
|
226
|
+
let files = fs.readdirSync(inboxDir)
|
|
227
|
+
.filter(f => f.endsWith('.json'))
|
|
228
|
+
.sort();
|
|
229
|
+
|
|
230
|
+
if (opts.after_id) {
|
|
231
|
+
const idx = files.findIndex(f => f.startsWith(opts.after_id));
|
|
232
|
+
if (idx >= 0) files = files.slice(idx + 1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (opts.from) {
|
|
236
|
+
files = files.filter(f => {
|
|
237
|
+
try {
|
|
238
|
+
const m = JSON.parse(fs.readFileSync(path.join(inboxDir, f), 'utf-8'));
|
|
239
|
+
return m.from === opts.from;
|
|
240
|
+
} catch { return false; }
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
files = files.slice(-limit);
|
|
245
|
+
|
|
246
|
+
return files.map(f => {
|
|
247
|
+
return JSON.parse(fs.readFileSync(path.join(inboxDir, f), 'utf-8'));
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
waitForMessages(agentName, timeoutMs = 30000) {
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
const timer = setTimeout(() => {
|
|
254
|
+
this._removeWaiter(agentName);
|
|
255
|
+
resolve({ timeout: true, messages: this.fetchMessages(agentName, 10) });
|
|
256
|
+
}, timeoutMs);
|
|
257
|
+
|
|
258
|
+
if (!this._waiters[agentName]) this._waiters[agentName] = [];
|
|
259
|
+
this._waiters[agentName].push({ resolve, timer });
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
deleteMessages(agentName, messageIds) {
|
|
264
|
+
const inboxDir = path.join(MSGS_DIR, agentName);
|
|
265
|
+
if (!fs.existsSync(inboxDir)) return { deleted: 0 };
|
|
266
|
+
|
|
267
|
+
let count = 0;
|
|
268
|
+
for (const id of messageIds) {
|
|
269
|
+
const filePath = path.join(inboxDir, `${id}.json`);
|
|
270
|
+
const filePathAlt = path.join(inboxDir, `${id}`);
|
|
271
|
+
try {
|
|
272
|
+
if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); count++; }
|
|
273
|
+
else if (fs.existsSync(filePathAlt)) { fs.unlinkSync(filePathAlt); count++; }
|
|
274
|
+
} catch {}
|
|
275
|
+
}
|
|
276
|
+
return { deleted: count };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Channels ────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
createChannel(channelId, topic = '', createdBy = '') {
|
|
282
|
+
if (fs.existsSync(path.join(CHANNELS_DIR, `${channelId}.json`))) {
|
|
283
|
+
return { created: false, error: 'Channel already exists' };
|
|
284
|
+
}
|
|
285
|
+
const channel = {
|
|
286
|
+
id: channelId,
|
|
287
|
+
topic,
|
|
288
|
+
created_by: createdBy,
|
|
289
|
+
created_at: new Date().toISOString(),
|
|
290
|
+
members: createdBy ? [createdBy] : [],
|
|
291
|
+
};
|
|
292
|
+
fs.writeFileSync(path.join(CHANNELS_DIR, `${channelId}.json`), JSON.stringify(channel, null, 2), 'utf-8');
|
|
293
|
+
this._logEvent('system', 'channel_created', { channel: channelId, by: createdBy });
|
|
294
|
+
return { created: true, channel };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
joinChannel(channelId, agentName) {
|
|
298
|
+
const channelPath = path.join(CHANNELS_DIR, `${channelId}.json`);
|
|
299
|
+
if (!fs.existsSync(channelPath)) return { ok: false, error: 'Channel not found' };
|
|
300
|
+
const channel = JSON.parse(fs.readFileSync(channelPath, 'utf-8'));
|
|
301
|
+
if (!channel.members.includes(agentName)) {
|
|
302
|
+
channel.members.push(agentName);
|
|
303
|
+
this._logEvent('system', 'channel_joined', { channel: channelId, agent: agentName });
|
|
304
|
+
}
|
|
305
|
+
fs.writeFileSync(channelPath, JSON.stringify(channel, null, 2), 'utf-8');
|
|
306
|
+
return { joined: true, channel };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
leaveChannel(channelId, agentName) {
|
|
310
|
+
const channelPath = path.join(CHANNELS_DIR, `${channelId}.json`);
|
|
311
|
+
if (!fs.existsSync(channelPath)) return { ok: false, error: 'Channel not found' };
|
|
312
|
+
const channel = JSON.parse(fs.readFileSync(channelPath, 'utf-8'));
|
|
313
|
+
channel.members = channel.members.filter(m => m !== agentName);
|
|
314
|
+
fs.writeFileSync(channelPath, JSON.stringify(channel, null, 2), 'utf-8');
|
|
315
|
+
this._logEvent('system', 'channel_left', { channel: channelId, agent: agentName });
|
|
316
|
+
return { left: true, channel };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
channelSend(channelId, from, message) {
|
|
320
|
+
const channelPath = path.join(CHANNELS_DIR, `${channelId}.json`);
|
|
321
|
+
if (!fs.existsSync(channelPath)) return { ok: false, error: 'Channel not found' };
|
|
322
|
+
|
|
323
|
+
const msg = {
|
|
324
|
+
id: this._genId(),
|
|
325
|
+
channel: channelId,
|
|
326
|
+
from,
|
|
327
|
+
message,
|
|
328
|
+
timestamp: new Date().toISOString(),
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const logDir = path.join(CHANNELS_DIR, channelId, 'log');
|
|
332
|
+
if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
|
|
333
|
+
fs.writeFileSync(path.join(logDir, `${msg.id}.json`), JSON.stringify(msg, null, 2), 'utf-8');
|
|
334
|
+
|
|
335
|
+
const channel = JSON.parse(fs.readFileSync(channelPath, 'utf-8'));
|
|
336
|
+
for (const member of channel.members) {
|
|
337
|
+
if (member !== from) {
|
|
338
|
+
this.sendMessage(from, member, `[${channelId}] ${message}`, { channel: channelId });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { sent: true, message_id: msg.id };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
channelHistory(channelId, limit = 50) {
|
|
346
|
+
const logDir = path.join(CHANNELS_DIR, channelId, 'log');
|
|
347
|
+
if (!fs.existsSync(logDir)) return [];
|
|
348
|
+
|
|
349
|
+
const files = fs.readdirSync(logDir)
|
|
350
|
+
.filter(f => f.endsWith('.json'))
|
|
351
|
+
.sort()
|
|
352
|
+
.slice(-limit);
|
|
353
|
+
|
|
354
|
+
return files.map(f => JSON.parse(fs.readFileSync(path.join(logDir, f), 'utf-8')));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── System Events / Discovery ────────────────────────
|
|
358
|
+
|
|
359
|
+
_broadcastSystemMessage(type, fromAgent, data) {
|
|
360
|
+
const text = JSON.stringify({ type, data });
|
|
361
|
+
for (const agentName of Object.keys(this._agents)) {
|
|
362
|
+
if (agentName !== fromAgent && agentName !== 'coco') {
|
|
363
|
+
this.sendMessage('coco', agentName, text, { type: 'system_event', event_type: type });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
_logEvent(category, type, data) {
|
|
369
|
+
const event = {
|
|
370
|
+
id: this._genId(),
|
|
371
|
+
category,
|
|
372
|
+
type,
|
|
373
|
+
data,
|
|
374
|
+
timestamp: new Date().toISOString(),
|
|
375
|
+
};
|
|
376
|
+
const dayFile = path.join(EVENTS_DIR, `${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
377
|
+
try {
|
|
378
|
+
fs.appendFileSync(dayFile, JSON.stringify(event) + '\n', 'utf-8');
|
|
379
|
+
} catch {}
|
|
380
|
+
this.emit('event', event);
|
|
381
|
+
// Wake event waiters
|
|
382
|
+
this._wakeEventWaiters(event);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
getEvents(opts = {}) {
|
|
386
|
+
const { since, category, type, limit = 100 } = opts;
|
|
387
|
+
|
|
388
|
+
if (!fs.existsSync(EVENTS_DIR)) return [];
|
|
389
|
+
|
|
390
|
+
// Determine which daily files to read
|
|
391
|
+
const allFiles = fs.readdirSync(EVENTS_DIR).filter(f => f.endsWith('.jsonl')).sort();
|
|
392
|
+
let filesToRead = allFiles;
|
|
393
|
+
|
|
394
|
+
if (since) {
|
|
395
|
+
const sinceDate = new Date(since).toISOString().slice(0, 10);
|
|
396
|
+
filesToRead = allFiles.filter(f => f >= sinceDate);
|
|
397
|
+
} else {
|
|
398
|
+
// Default: only today (backward compatible)
|
|
399
|
+
filesToRead = allFiles.slice(-1);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let events = [];
|
|
403
|
+
for (const dayFile of filesToRead) {
|
|
404
|
+
try {
|
|
405
|
+
const lines = fs.readFileSync(path.join(EVENTS_DIR, dayFile), 'utf-8').split('\n').filter(Boolean);
|
|
406
|
+
for (const line of lines) {
|
|
407
|
+
try { events.push(JSON.parse(line)); } catch {}
|
|
408
|
+
}
|
|
409
|
+
} catch {}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (since) {
|
|
413
|
+
const sinceTs = new Date(since).getTime();
|
|
414
|
+
events = events.filter(e => e.timestamp && new Date(e.timestamp).getTime() >= sinceTs);
|
|
415
|
+
}
|
|
416
|
+
if (category) events = events.filter(e => e.category === category);
|
|
417
|
+
if (type) events = events.filter(e => e.type === type);
|
|
418
|
+
|
|
419
|
+
// Sort by timestamp ascending
|
|
420
|
+
events.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
|
|
421
|
+
|
|
422
|
+
return events.slice(-limit);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
waitForEvents(category, type, timeoutMs = 30000) {
|
|
426
|
+
return new Promise((resolve) => {
|
|
427
|
+
const key = `${category || '*'}:${type || '*'}`;
|
|
428
|
+
const timer = setTimeout(() => {
|
|
429
|
+
this._removeEventWaiter(key);
|
|
430
|
+
resolve({ timeout: true, events: [] });
|
|
431
|
+
}, timeoutMs);
|
|
432
|
+
|
|
433
|
+
if (!this._eventWaiters[key]) this._eventWaiters[key] = [];
|
|
434
|
+
this._eventWaiters[key].push({ resolve, timer, category, type });
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
_wakeEventWaiters(event) {
|
|
439
|
+
const keys = [
|
|
440
|
+
`${event.category}:${event.type}`,
|
|
441
|
+
`${event.category}:*`,
|
|
442
|
+
`*:${event.type}`,
|
|
443
|
+
`*:*`,
|
|
444
|
+
];
|
|
445
|
+
for (const key of keys) {
|
|
446
|
+
const waiters = this._eventWaiters[key] || [];
|
|
447
|
+
for (const w of waiters) {
|
|
448
|
+
clearTimeout(w.timer);
|
|
449
|
+
w.resolve({ timeout: false, events: [event] });
|
|
450
|
+
}
|
|
451
|
+
if (waiters.length > 0) delete this._eventWaiters[key];
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
_removeEventWaiter(key) {
|
|
456
|
+
delete this._eventWaiters[key];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── Stale Agent Detection ────────────────────────────
|
|
460
|
+
|
|
461
|
+
_checkStaleAgents() {
|
|
462
|
+
setInterval(() => {
|
|
463
|
+
const now = Date.now();
|
|
464
|
+
for (const [name, agent] of Object.entries(this._agents)) {
|
|
465
|
+
const lastSeen = new Date(agent.last_seen).getTime();
|
|
466
|
+
if (now - lastSeen > OFFLINE_TIMEOUT_MS && agent.status !== 'offline') {
|
|
467
|
+
agent.status = 'offline';
|
|
468
|
+
this._logEvent('system', 'agent_offline', { agent: name });
|
|
469
|
+
this._broadcastSystemMessage('system:agent_left', name, {
|
|
470
|
+
agent: name,
|
|
471
|
+
reason: 'heartbeat timeout',
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
this._saveJson(AGENTS_FILE, this._agents);
|
|
476
|
+
}, 60000); // Check every minute
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
_logSystemWelcome(name) {
|
|
480
|
+
this._logEvent('system', 'welcome_sent', { agent: name });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── Internal ────────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
_wakeWaiters(agentName, msg) {
|
|
486
|
+
const waiters = this._waiters[agentName] || [];
|
|
487
|
+
delete this._waiters[agentName];
|
|
488
|
+
for (const w of waiters) {
|
|
489
|
+
clearTimeout(w.timer);
|
|
490
|
+
w.resolve({ timeout: false, messages: [msg] });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
_removeWaiter(agentName) {
|
|
495
|
+
delete this._waiters[agentName];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
_genId() {
|
|
499
|
+
return `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
_loadJson(filePath, defaultVal) {
|
|
503
|
+
try {
|
|
504
|
+
if (fs.existsSync(filePath)) {
|
|
505
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
506
|
+
}
|
|
507
|
+
} catch {}
|
|
508
|
+
return defaultVal;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
_saveJson(filePath, data) {
|
|
512
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
module.exports = { AgentBus };
|
package/lib/daemon.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon Manager — run MCP CoCo as a background process
|
|
3
|
+
* with healthcheck, PID tracking, and auto-recovery for Hermes backend.
|
|
4
|
+
*/
|
|
5
|
+
const { spawn } = require('child_process');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
|
|
9
|
+
const PID_FILE = path.join(__dirname, '..', '.coco-daemon.pid');
|
|
10
|
+
const LOG_FILE = path.join(__dirname, '..', 'coco-daemon.log');
|
|
11
|
+
const HERMES_MCP_PID = path.join(__dirname, '..', '.hermes-mcp.pid');
|
|
12
|
+
|
|
13
|
+
class Daemon {
|
|
14
|
+
/**
|
|
15
|
+
* Start the MCP CoCo daemon in background.
|
|
16
|
+
* Returns immediately; the child process runs independently.
|
|
17
|
+
*/
|
|
18
|
+
async start() {
|
|
19
|
+
// Check if already running
|
|
20
|
+
if (fs.existsSync(PID_FILE)) {
|
|
21
|
+
const existingPid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim());
|
|
22
|
+
try {
|
|
23
|
+
process.kill(existingPid, 0);
|
|
24
|
+
console.error(`MCP CoCo already running (PID: ${existingPid})`);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
return;
|
|
27
|
+
} catch {
|
|
28
|
+
// Stale PID file, remove it
|
|
29
|
+
fs.unlinkSync(PID_FILE);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fork ourselves as daemon
|
|
34
|
+
const child = spawn(process.execPath, [path.join(__dirname, '..', 'index.js')], {
|
|
35
|
+
detached: true,
|
|
36
|
+
stdio: ['ignore', fs.openSync(LOG_FILE, 'a'), fs.openSync(LOG_FILE, 'a')],
|
|
37
|
+
env: { ...process.env, MCP_COCO_DAEMON: '1' },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
child.unref();
|
|
41
|
+
|
|
42
|
+
// Write PID
|
|
43
|
+
fs.writeFileSync(PID_FILE, String(child.pid), 'utf-8');
|
|
44
|
+
|
|
45
|
+
console.log(`MCP CoCo daemon started (PID: ${child.pid})`);
|
|
46
|
+
console.log(`Log: ${LOG_FILE}`);
|
|
47
|
+
|
|
48
|
+
// Also start Hermes MCP backend
|
|
49
|
+
await this._startHermesMCP();
|
|
50
|
+
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Start Hermes MCP serve as a subprocess of the daemon.
|
|
56
|
+
*/
|
|
57
|
+
async _startHermesMCP() {
|
|
58
|
+
if (fs.existsSync(HERMES_MCP_PID)) {
|
|
59
|
+
try {
|
|
60
|
+
const pid = parseInt(fs.readFileSync(HERMES_MCP_PID, 'utf-8').trim());
|
|
61
|
+
process.kill(pid, 0);
|
|
62
|
+
return; // already running
|
|
63
|
+
} catch {
|
|
64
|
+
fs.unlinkSync(HERMES_MCP_PID);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });
|
|
69
|
+
const child = spawn('hermes', ['mcp', 'serve', '--accept-hooks'], {
|
|
70
|
+
detached: true,
|
|
71
|
+
stdio: ['ignore', logStream, logStream],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
child.unref();
|
|
75
|
+
fs.writeFileSync(HERMES_MCP_PID, String(child.pid), 'utf-8');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Quick health check — used by --health flag and mcporter probe.
|
|
80
|
+
*/
|
|
81
|
+
async healthCheck() {
|
|
82
|
+
// Check if daemon PID is alive
|
|
83
|
+
if (!fs.existsSync(PID_FILE)) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim());
|
|
88
|
+
process.kill(pid, 0);
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { Daemon };
|