let-them-talk 2.0.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/cli.js +233 -0
- package/dashboard.html +1719 -0
- package/dashboard.js +359 -0
- package/package.json +46 -0
- package/server.js +727 -0
package/server.js
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
2
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
3
|
+
const {
|
|
4
|
+
CallToolRequestSchema,
|
|
5
|
+
ListToolsRequestSchema,
|
|
6
|
+
} = require('@modelcontextprotocol/sdk/types.js');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
// Data dir lives in the project where Claude Code runs, not where the package is installed
|
|
11
|
+
const DATA_DIR = process.env.AGENT_BRIDGE_DATA_DIR || path.join(process.cwd(), '.agent-bridge');
|
|
12
|
+
const MESSAGES_FILE = path.join(DATA_DIR, 'messages.jsonl');
|
|
13
|
+
const HISTORY_FILE = path.join(DATA_DIR, 'history.jsonl');
|
|
14
|
+
const AGENTS_FILE = path.join(DATA_DIR, 'agents.json');
|
|
15
|
+
const ACKS_FILE = path.join(DATA_DIR, 'acks.json');
|
|
16
|
+
|
|
17
|
+
// In-memory state for this process
|
|
18
|
+
let registeredName = null;
|
|
19
|
+
let lastReadOffset = 0; // byte offset into messages.jsonl for efficient polling
|
|
20
|
+
let heartbeatInterval = null; // heartbeat timer reference
|
|
21
|
+
|
|
22
|
+
// --- Helpers ---
|
|
23
|
+
|
|
24
|
+
function ensureDataDir() {
|
|
25
|
+
if (!fs.existsSync(DATA_DIR)) {
|
|
26
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sanitizeName(name) {
|
|
31
|
+
if (typeof name !== 'string' || !/^[a-zA-Z0-9_-]{1,20}$/.test(name)) {
|
|
32
|
+
throw new Error(`Invalid name "${name}": must be 1-20 alphanumeric/underscore/hyphen chars`);
|
|
33
|
+
}
|
|
34
|
+
return name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function consumedFile(agentName) {
|
|
38
|
+
sanitizeName(agentName);
|
|
39
|
+
return path.join(DATA_DIR, `consumed-${agentName}.json`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getConsumedIds(agentName) {
|
|
43
|
+
const file = consumedFile(agentName);
|
|
44
|
+
if (!fs.existsSync(file)) return new Set();
|
|
45
|
+
try {
|
|
46
|
+
return new Set(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
47
|
+
} catch {
|
|
48
|
+
return new Set();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function saveConsumedIds(agentName, ids) {
|
|
53
|
+
fs.writeFileSync(consumedFile(agentName), JSON.stringify([...ids]));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readJsonl(file) {
|
|
57
|
+
if (!fs.existsSync(file)) return [];
|
|
58
|
+
const content = fs.readFileSync(file, 'utf8').trim();
|
|
59
|
+
if (!content) return [];
|
|
60
|
+
return content.split('\n').map(line => {
|
|
61
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
62
|
+
}).filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getAgents() {
|
|
66
|
+
if (!fs.existsSync(AGENTS_FILE)) return {};
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(fs.readFileSync(AGENTS_FILE, 'utf8'));
|
|
69
|
+
} catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function saveAgents(agents) {
|
|
75
|
+
fs.writeFileSync(AGENTS_FILE, JSON.stringify(agents, null, 2));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getAcks() {
|
|
79
|
+
if (!fs.existsSync(ACKS_FILE)) return {};
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(fs.readFileSync(ACKS_FILE, 'utf8'));
|
|
82
|
+
} catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isPidAlive(pid) {
|
|
88
|
+
try {
|
|
89
|
+
process.kill(pid, 0);
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function generateId() {
|
|
97
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sleep(ms) {
|
|
101
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Read new lines from messages.jsonl starting at a byte offset
|
|
105
|
+
function readNewMessages(fromOffset) {
|
|
106
|
+
if (!fs.existsSync(MESSAGES_FILE)) return { messages: [], newOffset: 0 };
|
|
107
|
+
const stat = fs.statSync(MESSAGES_FILE);
|
|
108
|
+
if (stat.size < fromOffset) return { messages: [], newOffset: 0 }; // file was truncated/replaced — reset offset
|
|
109
|
+
if (stat.size === fromOffset) return { messages: [], newOffset: fromOffset };
|
|
110
|
+
|
|
111
|
+
const fd = fs.openSync(MESSAGES_FILE, 'r');
|
|
112
|
+
const buf = Buffer.alloc(stat.size - fromOffset);
|
|
113
|
+
fs.readSync(fd, buf, 0, buf.length, fromOffset);
|
|
114
|
+
fs.closeSync(fd);
|
|
115
|
+
|
|
116
|
+
const chunk = buf.toString('utf8').trim();
|
|
117
|
+
if (!chunk) return { messages: [], newOffset: stat.size };
|
|
118
|
+
|
|
119
|
+
const messages = chunk.split('\n').map(line => {
|
|
120
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
121
|
+
}).filter(Boolean);
|
|
122
|
+
|
|
123
|
+
return { messages, newOffset: stat.size };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Get unconsumed messages for an agent (full scan — used by check_messages and initial load)
|
|
127
|
+
function getUnconsumedMessages(agentName, fromFilter = null) {
|
|
128
|
+
const messages = readJsonl(MESSAGES_FILE);
|
|
129
|
+
const consumed = getConsumedIds(agentName);
|
|
130
|
+
return messages.filter(m => {
|
|
131
|
+
if (m.to !== agentName) return false;
|
|
132
|
+
if (consumed.has(m.id)) return false;
|
|
133
|
+
if (fromFilter && m.from !== fromFilter) return false;
|
|
134
|
+
return true;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- Tool implementations ---
|
|
139
|
+
|
|
140
|
+
function toolRegister(name) {
|
|
141
|
+
ensureDataDir();
|
|
142
|
+
sanitizeName(name);
|
|
143
|
+
|
|
144
|
+
const agents = getAgents();
|
|
145
|
+
if (agents[name] && agents[name].pid !== process.pid && isPidAlive(agents[name].pid)) {
|
|
146
|
+
return { error: `Agent "${name}" is already registered by a live process (PID ${agents[name].pid})` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Clean up old registration if re-registering with a different name
|
|
150
|
+
if (registeredName && registeredName !== name && agents[registeredName] && agents[registeredName].pid === process.pid) {
|
|
151
|
+
delete agents[registeredName];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const now = new Date().toISOString();
|
|
155
|
+
agents[name] = { pid: process.pid, timestamp: now, last_activity: now };
|
|
156
|
+
saveAgents(agents);
|
|
157
|
+
registeredName = name;
|
|
158
|
+
|
|
159
|
+
// Start heartbeat — updates last_activity every 10s so dashboard knows we're alive
|
|
160
|
+
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
|
161
|
+
heartbeatInterval = setInterval(() => {
|
|
162
|
+
try {
|
|
163
|
+
const agents = getAgents();
|
|
164
|
+
if (agents[registeredName]) {
|
|
165
|
+
agents[registeredName].last_activity = new Date().toISOString();
|
|
166
|
+
saveAgents(agents);
|
|
167
|
+
}
|
|
168
|
+
} catch {}
|
|
169
|
+
}, 10000);
|
|
170
|
+
heartbeatInterval.unref(); // Don't prevent process exit
|
|
171
|
+
|
|
172
|
+
return { success: true, message: `Registered as Agent ${name} (PID ${process.pid})` };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Update last_activity timestamp for this agent
|
|
176
|
+
function touchActivity() {
|
|
177
|
+
if (!registeredName) return;
|
|
178
|
+
try {
|
|
179
|
+
const agents = getAgents();
|
|
180
|
+
if (agents[registeredName]) {
|
|
181
|
+
agents[registeredName].last_activity = new Date().toISOString();
|
|
182
|
+
saveAgents(agents);
|
|
183
|
+
}
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Set or clear the listening_since flag
|
|
188
|
+
function setListening(isListening) {
|
|
189
|
+
if (!registeredName) return;
|
|
190
|
+
try {
|
|
191
|
+
const agents = getAgents();
|
|
192
|
+
if (agents[registeredName]) {
|
|
193
|
+
agents[registeredName].listening_since = isListening ? new Date().toISOString() : null;
|
|
194
|
+
saveAgents(agents);
|
|
195
|
+
}
|
|
196
|
+
} catch {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function toolListAgents() {
|
|
200
|
+
const agents = getAgents();
|
|
201
|
+
const result = {};
|
|
202
|
+
for (const [name, info] of Object.entries(agents)) {
|
|
203
|
+
const alive = isPidAlive(info.pid);
|
|
204
|
+
const lastActivity = info.last_activity || info.timestamp;
|
|
205
|
+
const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
206
|
+
result[name] = {
|
|
207
|
+
pid: info.pid,
|
|
208
|
+
alive,
|
|
209
|
+
registered_at: info.timestamp,
|
|
210
|
+
last_activity: lastActivity,
|
|
211
|
+
idle_seconds: alive ? idleSeconds : null,
|
|
212
|
+
status: !alive ? 'dead' : idleSeconds > 60 ? 'sleeping' : 'active',
|
|
213
|
+
listening_since: info.listening_since || null,
|
|
214
|
+
is_listening: !!(info.listening_since && alive),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { agents: result };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function toolSendMessage(content, to = null, reply_to = null) {
|
|
221
|
+
if (!registeredName) {
|
|
222
|
+
return { error: 'You must call register() first' };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const agents = getAgents();
|
|
226
|
+
const otherAgents = Object.keys(agents).filter(n => n !== registeredName);
|
|
227
|
+
|
|
228
|
+
if (otherAgents.length === 0) {
|
|
229
|
+
return { error: 'No other agents registered' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Auto-route when exactly 1 other agent, otherwise require explicit `to`
|
|
233
|
+
if (!to) {
|
|
234
|
+
if (otherAgents.length === 1) {
|
|
235
|
+
to = otherAgents[0];
|
|
236
|
+
} else {
|
|
237
|
+
return { error: `Multiple agents online (${otherAgents.join(', ')}). Specify 'to' parameter.` };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!agents[to]) {
|
|
242
|
+
return { error: `Agent "${to}" is not registered` };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (to === registeredName) {
|
|
246
|
+
return { error: 'Cannot send a message to yourself' };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Resolve threading
|
|
250
|
+
let thread_id = null;
|
|
251
|
+
if (reply_to) {
|
|
252
|
+
const allMsgs = readJsonl(MESSAGES_FILE);
|
|
253
|
+
const referencedMsg = allMsgs.find(m => m.id === reply_to);
|
|
254
|
+
if (referencedMsg) {
|
|
255
|
+
thread_id = referencedMsg.thread_id || referencedMsg.id;
|
|
256
|
+
} else {
|
|
257
|
+
thread_id = reply_to; // referenced msg may be purged, use ID anyway
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const msg = {
|
|
262
|
+
id: generateId(),
|
|
263
|
+
from: registeredName,
|
|
264
|
+
to,
|
|
265
|
+
content,
|
|
266
|
+
timestamp: new Date().toISOString(),
|
|
267
|
+
...(reply_to && { reply_to }),
|
|
268
|
+
...(thread_id && { thread_id }),
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
ensureDataDir();
|
|
272
|
+
fs.appendFileSync(MESSAGES_FILE, JSON.stringify(msg) + '\n');
|
|
273
|
+
fs.appendFileSync(HISTORY_FILE, JSON.stringify(msg) + '\n');
|
|
274
|
+
touchActivity();
|
|
275
|
+
|
|
276
|
+
return { success: true, messageId: msg.id, from: msg.from, to: msg.to };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function toolWaitForReply(timeoutSeconds = 300, from = null) {
|
|
280
|
+
if (!registeredName) {
|
|
281
|
+
return { error: 'You must call register() first' };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
setListening(true);
|
|
285
|
+
|
|
286
|
+
// First check any already-existing unconsumed messages (handles startup/catch-up)
|
|
287
|
+
const existing = getUnconsumedMessages(registeredName, from);
|
|
288
|
+
if (existing.length > 0) {
|
|
289
|
+
const msg = existing[0];
|
|
290
|
+
const consumed = getConsumedIds(registeredName);
|
|
291
|
+
consumed.add(msg.id);
|
|
292
|
+
saveConsumedIds(registeredName, consumed);
|
|
293
|
+
if (fs.existsSync(MESSAGES_FILE)) {
|
|
294
|
+
lastReadOffset = fs.statSync(MESSAGES_FILE).size;
|
|
295
|
+
}
|
|
296
|
+
touchActivity();
|
|
297
|
+
setListening(false);
|
|
298
|
+
return {
|
|
299
|
+
success: true,
|
|
300
|
+
message: {
|
|
301
|
+
id: msg.id,
|
|
302
|
+
from: msg.from,
|
|
303
|
+
content: msg.content,
|
|
304
|
+
timestamp: msg.timestamp,
|
|
305
|
+
...(msg.reply_to && { reply_to: msg.reply_to }),
|
|
306
|
+
...(msg.thread_id && { thread_id: msg.thread_id }),
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Set offset to current file end before polling for new messages
|
|
312
|
+
if (fs.existsSync(MESSAGES_FILE)) {
|
|
313
|
+
lastReadOffset = fs.statSync(MESSAGES_FILE).size;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
317
|
+
const consumed = getConsumedIds(registeredName);
|
|
318
|
+
|
|
319
|
+
while (Date.now() < deadline) {
|
|
320
|
+
const { messages: newMsgs, newOffset } = readNewMessages(lastReadOffset);
|
|
321
|
+
lastReadOffset = newOffset;
|
|
322
|
+
|
|
323
|
+
for (const msg of newMsgs) {
|
|
324
|
+
if (msg.to !== registeredName || consumed.has(msg.id)) continue;
|
|
325
|
+
if (from && msg.from !== from) continue;
|
|
326
|
+
|
|
327
|
+
consumed.add(msg.id);
|
|
328
|
+
saveConsumedIds(registeredName, consumed);
|
|
329
|
+
touchActivity();
|
|
330
|
+
setListening(false);
|
|
331
|
+
return {
|
|
332
|
+
success: true,
|
|
333
|
+
message: {
|
|
334
|
+
id: msg.id,
|
|
335
|
+
from: msg.from,
|
|
336
|
+
content: msg.content,
|
|
337
|
+
timestamp: msg.timestamp,
|
|
338
|
+
...(msg.reply_to && { reply_to: msg.reply_to }),
|
|
339
|
+
...(msg.thread_id && { thread_id: msg.thread_id }),
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
await sleep(500);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
setListening(false);
|
|
347
|
+
return {
|
|
348
|
+
timeout: true,
|
|
349
|
+
message: `No reply received within ${timeoutSeconds}s. Call wait_for_reply() again to keep waiting.`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function toolCheckMessages(from = null) {
|
|
354
|
+
if (!registeredName) {
|
|
355
|
+
return { error: 'You must call register() first' };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const unconsumed = getUnconsumedMessages(registeredName, from);
|
|
359
|
+
return {
|
|
360
|
+
count: unconsumed.length,
|
|
361
|
+
messages: unconsumed.map(m => ({
|
|
362
|
+
id: m.id,
|
|
363
|
+
from: m.from,
|
|
364
|
+
content: m.content,
|
|
365
|
+
timestamp: m.timestamp,
|
|
366
|
+
...(m.reply_to && { reply_to: m.reply_to }),
|
|
367
|
+
...(m.thread_id && { thread_id: m.thread_id }),
|
|
368
|
+
})),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function toolAckMessage(messageId) {
|
|
373
|
+
if (!registeredName) {
|
|
374
|
+
return { error: 'You must call register() first' };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const acks = getAcks();
|
|
378
|
+
acks[messageId] = {
|
|
379
|
+
acked_by: registeredName,
|
|
380
|
+
acked_at: new Date().toISOString(),
|
|
381
|
+
};
|
|
382
|
+
fs.writeFileSync(ACKS_FILE, JSON.stringify(acks, null, 2));
|
|
383
|
+
touchActivity();
|
|
384
|
+
|
|
385
|
+
return { success: true, message: `Message ${messageId} acknowledged` };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Listen indefinitely — loops wait_for_reply in 5-min chunks until a message arrives
|
|
389
|
+
async function toolListen(from = null) {
|
|
390
|
+
if (!registeredName) {
|
|
391
|
+
return { error: 'You must call register() first' };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
setListening(true);
|
|
395
|
+
|
|
396
|
+
// Check for existing unconsumed messages first
|
|
397
|
+
const existing = getUnconsumedMessages(registeredName, from);
|
|
398
|
+
if (existing.length > 0) {
|
|
399
|
+
const msg = existing[0];
|
|
400
|
+
const consumed = getConsumedIds(registeredName);
|
|
401
|
+
consumed.add(msg.id);
|
|
402
|
+
saveConsumedIds(registeredName, consumed);
|
|
403
|
+
if (fs.existsSync(MESSAGES_FILE)) {
|
|
404
|
+
lastReadOffset = fs.statSync(MESSAGES_FILE).size;
|
|
405
|
+
}
|
|
406
|
+
touchActivity();
|
|
407
|
+
setListening(false);
|
|
408
|
+
return {
|
|
409
|
+
success: true,
|
|
410
|
+
message: {
|
|
411
|
+
id: msg.id,
|
|
412
|
+
from: msg.from,
|
|
413
|
+
content: msg.content,
|
|
414
|
+
timestamp: msg.timestamp,
|
|
415
|
+
...(msg.reply_to && { reply_to: msg.reply_to }),
|
|
416
|
+
...(msg.thread_id && { thread_id: msg.thread_id }),
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Set offset to current file end
|
|
422
|
+
if (fs.existsSync(MESSAGES_FILE)) {
|
|
423
|
+
lastReadOffset = fs.statSync(MESSAGES_FILE).size;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const consumed = getConsumedIds(registeredName);
|
|
427
|
+
|
|
428
|
+
// Poll indefinitely (in 5-min chunks to stay within any MCP limits)
|
|
429
|
+
while (true) {
|
|
430
|
+
const chunkDeadline = Date.now() + 300000; // 5 minutes
|
|
431
|
+
|
|
432
|
+
while (Date.now() < chunkDeadline) {
|
|
433
|
+
const { messages: newMsgs, newOffset } = readNewMessages(lastReadOffset);
|
|
434
|
+
lastReadOffset = newOffset;
|
|
435
|
+
|
|
436
|
+
for (const msg of newMsgs) {
|
|
437
|
+
if (msg.to !== registeredName || consumed.has(msg.id)) continue;
|
|
438
|
+
if (from && msg.from !== from) continue;
|
|
439
|
+
|
|
440
|
+
consumed.add(msg.id);
|
|
441
|
+
saveConsumedIds(registeredName, consumed);
|
|
442
|
+
touchActivity();
|
|
443
|
+
setListening(false);
|
|
444
|
+
return {
|
|
445
|
+
success: true,
|
|
446
|
+
message: {
|
|
447
|
+
id: msg.id,
|
|
448
|
+
from: msg.from,
|
|
449
|
+
content: msg.content,
|
|
450
|
+
timestamp: msg.timestamp,
|
|
451
|
+
...(msg.reply_to && { reply_to: msg.reply_to }),
|
|
452
|
+
...(msg.thread_id && { thread_id: msg.thread_id }),
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
await sleep(500);
|
|
457
|
+
}
|
|
458
|
+
// No message in this 5-min chunk — loop again (stay listening)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function toolGetHistory(limit = 50, thread_id = null) {
|
|
463
|
+
let history = readJsonl(HISTORY_FILE);
|
|
464
|
+
if (thread_id) {
|
|
465
|
+
history = history.filter(m => m.thread_id === thread_id || m.id === thread_id);
|
|
466
|
+
}
|
|
467
|
+
const recent = history.slice(-limit);
|
|
468
|
+
const acks = getAcks();
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
count: recent.length,
|
|
472
|
+
total: history.length,
|
|
473
|
+
messages: recent.map(m => ({
|
|
474
|
+
id: m.id,
|
|
475
|
+
from: m.from,
|
|
476
|
+
to: m.to,
|
|
477
|
+
content: m.content,
|
|
478
|
+
timestamp: m.timestamp,
|
|
479
|
+
acked: !!acks[m.id],
|
|
480
|
+
...(m.reply_to && { reply_to: m.reply_to }),
|
|
481
|
+
...(m.thread_id && { thread_id: m.thread_id }),
|
|
482
|
+
})),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function toolReset() {
|
|
487
|
+
// Remove known fixed files
|
|
488
|
+
for (const f of [MESSAGES_FILE, HISTORY_FILE, AGENTS_FILE, ACKS_FILE]) {
|
|
489
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
490
|
+
}
|
|
491
|
+
// Glob for all consumed-*.json files (dynamic agent names)
|
|
492
|
+
if (fs.existsSync(DATA_DIR)) {
|
|
493
|
+
const files = fs.readdirSync(DATA_DIR);
|
|
494
|
+
for (const f of files) {
|
|
495
|
+
if (f.startsWith('consumed-') && f.endsWith('.json')) {
|
|
496
|
+
fs.unlinkSync(path.join(DATA_DIR, f));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
registeredName = null;
|
|
501
|
+
lastReadOffset = 0;
|
|
502
|
+
if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; }
|
|
503
|
+
return { success: true, message: 'All data cleared. Ready for a fresh session.' };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// --- MCP Server setup ---
|
|
507
|
+
|
|
508
|
+
const server = new Server(
|
|
509
|
+
{ name: 'agent-bridge', version: '2.0.0' },
|
|
510
|
+
{ capabilities: { tools: {} } }
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
514
|
+
return {
|
|
515
|
+
tools: [
|
|
516
|
+
{
|
|
517
|
+
name: 'register',
|
|
518
|
+
description: 'Register this agent\'s identity (any name, e.g. "A", "Coder", "Reviewer"). Must be called before any other tool.',
|
|
519
|
+
inputSchema: {
|
|
520
|
+
type: 'object',
|
|
521
|
+
properties: {
|
|
522
|
+
name: {
|
|
523
|
+
type: 'string',
|
|
524
|
+
description: 'Agent name (1-20 alphanumeric/underscore/hyphen chars)',
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
required: ['name'],
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
name: 'list_agents',
|
|
532
|
+
description: 'List all registered agents with their status (alive/dead).',
|
|
533
|
+
inputSchema: {
|
|
534
|
+
type: 'object',
|
|
535
|
+
properties: {},
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
name: 'send_message',
|
|
540
|
+
description: 'Send a message to another agent. Auto-routes when only 2 agents are online; otherwise specify recipient.',
|
|
541
|
+
inputSchema: {
|
|
542
|
+
type: 'object',
|
|
543
|
+
properties: {
|
|
544
|
+
content: {
|
|
545
|
+
type: 'string',
|
|
546
|
+
description: 'The message content to send',
|
|
547
|
+
},
|
|
548
|
+
to: {
|
|
549
|
+
type: 'string',
|
|
550
|
+
description: 'Recipient agent name (optional if only 2 agents online)',
|
|
551
|
+
},
|
|
552
|
+
reply_to: {
|
|
553
|
+
type: 'string',
|
|
554
|
+
description: 'ID of a previous message to thread this reply under (optional)',
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
required: ['content'],
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
name: 'wait_for_reply',
|
|
562
|
+
description: 'Block and poll for a message addressed to you. Returns when a message arrives or on timeout. Call again if it times out.',
|
|
563
|
+
inputSchema: {
|
|
564
|
+
type: 'object',
|
|
565
|
+
properties: {
|
|
566
|
+
timeout_seconds: {
|
|
567
|
+
type: 'number',
|
|
568
|
+
description: 'How long to wait in seconds (default: 300)',
|
|
569
|
+
},
|
|
570
|
+
from: {
|
|
571
|
+
type: 'string',
|
|
572
|
+
description: 'Only return messages from this specific agent (optional)',
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
name: 'listen',
|
|
579
|
+
description: 'Listen for messages indefinitely. Unlike wait_for_reply, this never times out — it blocks until a message arrives. The agent should call listen() after finishing any task to stay available. After receiving a message, process it, respond, then call listen() again.',
|
|
580
|
+
inputSchema: {
|
|
581
|
+
type: 'object',
|
|
582
|
+
properties: {
|
|
583
|
+
from: {
|
|
584
|
+
type: 'string',
|
|
585
|
+
description: 'Only listen for messages from this specific agent (optional)',
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
name: 'check_messages',
|
|
592
|
+
description: 'Non-blocking peek at unconsumed messages addressed to you. Does not mark them as read.',
|
|
593
|
+
inputSchema: {
|
|
594
|
+
type: 'object',
|
|
595
|
+
properties: {
|
|
596
|
+
from: {
|
|
597
|
+
type: 'string',
|
|
598
|
+
description: 'Only show messages from this specific agent (optional)',
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
name: 'ack_message',
|
|
605
|
+
description: 'Acknowledge that you have processed a message. Lets the sender verify delivery via get_history.',
|
|
606
|
+
inputSchema: {
|
|
607
|
+
type: 'object',
|
|
608
|
+
properties: {
|
|
609
|
+
message_id: {
|
|
610
|
+
type: 'string',
|
|
611
|
+
description: 'ID of the message to acknowledge',
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
required: ['message_id'],
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
name: 'get_history',
|
|
619
|
+
description: 'Get conversation history. Optionally filter by thread.',
|
|
620
|
+
inputSchema: {
|
|
621
|
+
type: 'object',
|
|
622
|
+
properties: {
|
|
623
|
+
limit: {
|
|
624
|
+
type: 'number',
|
|
625
|
+
description: 'Number of recent messages to return (default: 50)',
|
|
626
|
+
},
|
|
627
|
+
thread_id: {
|
|
628
|
+
type: 'string',
|
|
629
|
+
description: 'Filter to only messages in this thread (optional)',
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
name: 'reset',
|
|
636
|
+
description: 'Clear all data files (messages, history, agents, acks) and start fresh.',
|
|
637
|
+
inputSchema: {
|
|
638
|
+
type: 'object',
|
|
639
|
+
properties: {},
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
],
|
|
643
|
+
};
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
647
|
+
const { name, arguments: args } = request.params;
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
let result;
|
|
651
|
+
|
|
652
|
+
switch (name) {
|
|
653
|
+
case 'register':
|
|
654
|
+
result = toolRegister(args.name);
|
|
655
|
+
break;
|
|
656
|
+
case 'list_agents':
|
|
657
|
+
result = toolListAgents();
|
|
658
|
+
break;
|
|
659
|
+
case 'send_message':
|
|
660
|
+
result = toolSendMessage(args.content, args?.to, args?.reply_to);
|
|
661
|
+
break;
|
|
662
|
+
case 'wait_for_reply':
|
|
663
|
+
result = await toolWaitForReply(args?.timeout_seconds, args?.from);
|
|
664
|
+
break;
|
|
665
|
+
case 'listen':
|
|
666
|
+
result = await toolListen(args?.from);
|
|
667
|
+
break;
|
|
668
|
+
case 'check_messages':
|
|
669
|
+
result = toolCheckMessages(args?.from);
|
|
670
|
+
break;
|
|
671
|
+
case 'ack_message':
|
|
672
|
+
result = toolAckMessage(args.message_id);
|
|
673
|
+
break;
|
|
674
|
+
case 'get_history':
|
|
675
|
+
result = toolGetHistory(args?.limit, args?.thread_id);
|
|
676
|
+
break;
|
|
677
|
+
case 'reset':
|
|
678
|
+
result = toolReset();
|
|
679
|
+
break;
|
|
680
|
+
default:
|
|
681
|
+
return {
|
|
682
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
683
|
+
isError: true,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (result.error) {
|
|
688
|
+
return {
|
|
689
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
690
|
+
isError: true,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
696
|
+
};
|
|
697
|
+
} catch (error) {
|
|
698
|
+
return {
|
|
699
|
+
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
|
700
|
+
isError: true,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// Clean up agent registration on exit for instant status updates
|
|
706
|
+
process.on('exit', () => {
|
|
707
|
+
if (registeredName) {
|
|
708
|
+
try {
|
|
709
|
+
const agents = getAgents();
|
|
710
|
+
if (agents[registeredName]) {
|
|
711
|
+
delete agents[registeredName];
|
|
712
|
+
saveAgents(agents);
|
|
713
|
+
}
|
|
714
|
+
} catch {}
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
718
|
+
process.on('SIGINT', () => process.exit(0));
|
|
719
|
+
|
|
720
|
+
async function main() {
|
|
721
|
+
ensureDataDir();
|
|
722
|
+
const transport = new StdioServerTransport();
|
|
723
|
+
await server.connect(transport);
|
|
724
|
+
console.error('Agent Bridge MCP server v2.0.0 running');
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
main().catch(console.error);
|