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/dashboard.js ADDED
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env node
2
+ const http = require('http');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const PORT = parseInt(process.env.AGENT_BRIDGE_PORT || '3000', 10);
7
+ const DEFAULT_DATA_DIR = process.env.AGENT_BRIDGE_DATA || path.join(process.cwd(), '.agent-bridge');
8
+ const HTML_FILE = path.join(__dirname, 'dashboard.html');
9
+ const PROJECTS_FILE = path.join(__dirname, 'projects.json');
10
+
11
+ // --- Multi-project support ---
12
+
13
+ function getProjects() {
14
+ if (!fs.existsSync(PROJECTS_FILE)) return [];
15
+ try { return JSON.parse(fs.readFileSync(PROJECTS_FILE, 'utf8')); } catch { return []; }
16
+ }
17
+
18
+ function saveProjects(projects) {
19
+ fs.writeFileSync(PROJECTS_FILE, JSON.stringify(projects, null, 2));
20
+ }
21
+
22
+ // Check if a directory has actual data files (not just an empty dir)
23
+ function hasDataFiles(dir) {
24
+ if (!fs.existsSync(dir)) return false;
25
+ try {
26
+ const files = fs.readdirSync(dir);
27
+ return files.some(f => f.endsWith('.jsonl') || f === 'agents.json');
28
+ } catch { return false; }
29
+ }
30
+
31
+ // Resolve data dir: explicit project path > env var > cwd > legacy fallback
32
+ // Prefers directories with actual data files over empty ones
33
+ function resolveDataDir(projectPath) {
34
+ if (projectPath) {
35
+ const dir = path.join(projectPath, '.agent-bridge');
36
+ const dataDir = path.join(projectPath, 'data');
37
+ // Prefer whichever has data
38
+ if (hasDataFiles(dir)) return dir;
39
+ if (hasDataFiles(dataDir)) return dataDir;
40
+ if (fs.existsSync(dir)) return dir;
41
+ if (fs.existsSync(dataDir)) return dataDir;
42
+ return dir;
43
+ }
44
+ const legacyDir = path.join(__dirname, 'data');
45
+ // Prefer dir with actual data files
46
+ if (hasDataFiles(DEFAULT_DATA_DIR)) return DEFAULT_DATA_DIR;
47
+ if (hasDataFiles(legacyDir)) return legacyDir;
48
+ if (fs.existsSync(DEFAULT_DATA_DIR)) return DEFAULT_DATA_DIR;
49
+ if (fs.existsSync(legacyDir)) return legacyDir;
50
+ return DEFAULT_DATA_DIR;
51
+ }
52
+
53
+ function filePath(name, projectPath) {
54
+ return path.join(resolveDataDir(projectPath), name);
55
+ }
56
+
57
+ // --- Shared helpers ---
58
+
59
+ function readJsonl(file) {
60
+ if (!fs.existsSync(file)) return [];
61
+ const content = fs.readFileSync(file, 'utf8').trim();
62
+ if (!content) return [];
63
+ return content.split('\n').map(line => {
64
+ try { return JSON.parse(line); } catch { return null; }
65
+ }).filter(Boolean);
66
+ }
67
+
68
+ function readJson(file) {
69
+ if (!fs.existsSync(file)) return {};
70
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
71
+ }
72
+
73
+ function isPidAlive(pid) {
74
+ try { process.kill(pid, 0); return true; } catch { return false; }
75
+ }
76
+
77
+ // --- API handlers ---
78
+
79
+ function apiHistory(query) {
80
+ const projectPath = query.get('project') || null;
81
+ const history = readJsonl(filePath('history.jsonl', projectPath));
82
+ const acks = readJson(filePath('acks.json', projectPath));
83
+ const limit = parseInt(query.get('limit') || '500', 10);
84
+ const threadId = query.get('thread_id');
85
+
86
+ let messages = history;
87
+ if (threadId) {
88
+ messages = messages.filter(m => m.thread_id === threadId || m.id === threadId);
89
+ }
90
+ messages = messages.slice(-limit);
91
+ messages.forEach(m => { m.acked = !!acks[m.id]; });
92
+ return messages;
93
+ }
94
+
95
+ function apiAgents(query) {
96
+ const projectPath = query.get('project') || null;
97
+ const agents = readJson(filePath('agents.json', projectPath));
98
+ const history = readJsonl(filePath('history.jsonl', projectPath));
99
+ const result = {};
100
+
101
+ // Build last message timestamp per agent from history
102
+ const lastMessageTime = {};
103
+ for (const m of history) {
104
+ lastMessageTime[m.from] = m.timestamp;
105
+ }
106
+
107
+ for (const [name, info] of Object.entries(agents)) {
108
+ const alive = isPidAlive(info.pid);
109
+ const lastActivity = info.last_activity || info.timestamp;
110
+ const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
111
+ result[name] = {
112
+ pid: info.pid,
113
+ alive,
114
+ registered_at: info.timestamp,
115
+ last_activity: lastActivity,
116
+ last_message: lastMessageTime[name] || null,
117
+ idle_seconds: alive ? idleSeconds : null,
118
+ status: !alive ? 'dead' : idleSeconds > 60 ? 'sleeping' : 'active',
119
+ listening_since: info.listening_since || null,
120
+ is_listening: !!(info.listening_since && alive),
121
+ };
122
+ }
123
+ return result;
124
+ }
125
+
126
+ function apiStatus(query) {
127
+ const projectPath = query.get('project') || null;
128
+ const history = readJsonl(filePath('history.jsonl', projectPath));
129
+ const agents = readJson(filePath('agents.json', projectPath));
130
+ const threads = new Set();
131
+ history.forEach(m => { if (m.thread_id) threads.add(m.thread_id); });
132
+
133
+ const agentEntries = Object.entries(agents);
134
+ const aliveCount = agentEntries.filter(([, a]) => isPidAlive(a.pid)).length;
135
+ const sleepingCount = agentEntries.filter(([, a]) => {
136
+ if (!isPidAlive(a.pid)) return false;
137
+ const lastActivity = a.last_activity || a.timestamp;
138
+ const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
139
+ return idleSeconds > 60;
140
+ }).length;
141
+
142
+ return {
143
+ messageCount: history.length,
144
+ agentCount: agentEntries.length,
145
+ aliveCount,
146
+ sleepingCount,
147
+ threadCount: threads.size,
148
+ };
149
+ }
150
+
151
+ function apiReset(query) {
152
+ const projectPath = query.get('project') || null;
153
+ const dataDir = resolveDataDir(projectPath);
154
+ const fixedFiles = ['messages.jsonl', 'history.jsonl', 'agents.json', 'acks.json'];
155
+ for (const f of fixedFiles) {
156
+ const p = path.join(dataDir, f);
157
+ if (fs.existsSync(p)) fs.unlinkSync(p);
158
+ }
159
+ if (fs.existsSync(dataDir)) {
160
+ for (const f of fs.readdirSync(dataDir)) {
161
+ if (f.startsWith('consumed-') && f.endsWith('.json')) {
162
+ fs.unlinkSync(path.join(dataDir, f));
163
+ }
164
+ }
165
+ }
166
+ return { success: true };
167
+ }
168
+
169
+ // Inject a message from the dashboard (system message or nudge to an agent)
170
+ function apiInjectMessage(body, query) {
171
+ const projectPath = query.get('project') || null;
172
+ const dataDir = resolveDataDir(projectPath);
173
+ const messagesFile = path.join(dataDir, 'messages.jsonl');
174
+ const historyFile = path.join(dataDir, 'history.jsonl');
175
+
176
+ if (!body.to || !body.content) {
177
+ return { error: 'Missing "to" and/or "content" fields' };
178
+ }
179
+
180
+ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
181
+ const fromName = body.from || 'Dashboard';
182
+ const now = new Date().toISOString();
183
+
184
+ // Broadcast to all agents
185
+ if (body.to === '__all__') {
186
+ const agents = readJson(path.join(dataDir, 'agents.json'));
187
+ const ids = [];
188
+ for (const name of Object.keys(agents)) {
189
+ const msg = {
190
+ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
191
+ from: fromName,
192
+ to: name,
193
+ content: body.content,
194
+ timestamp: now,
195
+ system: true,
196
+ };
197
+ fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
198
+ fs.appendFileSync(historyFile, JSON.stringify(msg) + '\n');
199
+ ids.push(msg.id);
200
+ }
201
+ return { success: true, messageIds: ids, broadcast: true };
202
+ }
203
+
204
+ const msg = {
205
+ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
206
+ from: fromName,
207
+ to: body.to,
208
+ content: body.content,
209
+ timestamp: now,
210
+ system: true,
211
+ };
212
+
213
+ fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
214
+ fs.appendFileSync(historyFile, JSON.stringify(msg) + '\n');
215
+
216
+ return { success: true, messageId: msg.id };
217
+ }
218
+
219
+ // Multi-project management
220
+ function apiProjects() {
221
+ return getProjects();
222
+ }
223
+
224
+ function apiAddProject(body) {
225
+ if (!body.path) return { error: 'Missing "path" field' };
226
+ const absPath = path.resolve(body.path);
227
+ if (!fs.existsSync(absPath)) return { error: `Path does not exist: ${absPath}` };
228
+
229
+ const projects = getProjects();
230
+ const name = body.name || path.basename(absPath);
231
+ if (projects.find(p => p.path === absPath)) return { error: 'Project already added' };
232
+
233
+ projects.push({ name, path: absPath, added_at: new Date().toISOString() });
234
+ saveProjects(projects);
235
+ return { success: true, project: { name, path: absPath } };
236
+ }
237
+
238
+ function apiRemoveProject(body) {
239
+ if (!body.path) return { error: 'Missing "path" field' };
240
+ const absPath = path.resolve(body.path);
241
+ let projects = getProjects();
242
+ const before = projects.length;
243
+ projects = projects.filter(p => p.path !== absPath);
244
+ if (projects.length === before) return { error: 'Project not found' };
245
+ saveProjects(projects);
246
+ return { success: true };
247
+ }
248
+
249
+ // --- HTTP Server ---
250
+
251
+ // Load HTML at startup (re-read on each request in dev for hot-reload)
252
+ let htmlContent = fs.readFileSync(HTML_FILE, 'utf8');
253
+
254
+ function parseBody(req) {
255
+ return new Promise((resolve, reject) => {
256
+ let data = '';
257
+ req.on('data', chunk => { data += chunk; });
258
+ req.on('end', () => {
259
+ try { resolve(JSON.parse(data)); } catch { reject(new Error('Invalid JSON body')); }
260
+ });
261
+ req.on('error', reject);
262
+ });
263
+ }
264
+
265
+ const server = http.createServer(async (req, res) => {
266
+ const url = new URL(req.url, 'http://localhost:' + PORT);
267
+
268
+ res.setHeader('Access-Control-Allow-Origin', '*');
269
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
270
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
271
+
272
+ if (req.method === 'OPTIONS') {
273
+ res.writeHead(204);
274
+ res.end();
275
+ return;
276
+ }
277
+
278
+ try {
279
+ // Serve dashboard HTML (re-read in dev mode for hot reload)
280
+ if (url.pathname === '/' || url.pathname === '/index.html') {
281
+ const html = process.env.NODE_ENV === 'development'
282
+ ? fs.readFileSync(HTML_FILE, 'utf8')
283
+ : htmlContent;
284
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
285
+ res.end(html);
286
+ }
287
+ // Existing APIs (now with ?project= param support)
288
+ else if (url.pathname === '/api/history' && req.method === 'GET') {
289
+ res.writeHead(200, { 'Content-Type': 'application/json' });
290
+ res.end(JSON.stringify(apiHistory(url.searchParams)));
291
+ }
292
+ else if (url.pathname === '/api/agents' && req.method === 'GET') {
293
+ res.writeHead(200, { 'Content-Type': 'application/json' });
294
+ res.end(JSON.stringify(apiAgents(url.searchParams)));
295
+ }
296
+ else if (url.pathname === '/api/status' && req.method === 'GET') {
297
+ res.writeHead(200, { 'Content-Type': 'application/json' });
298
+ res.end(JSON.stringify(apiStatus(url.searchParams)));
299
+ }
300
+ else if (url.pathname === '/api/reset' && req.method === 'POST') {
301
+ res.writeHead(200, { 'Content-Type': 'application/json' });
302
+ res.end(JSON.stringify(apiReset(url.searchParams)));
303
+ }
304
+ // Message injection
305
+ else if (url.pathname === '/api/inject' && req.method === 'POST') {
306
+ const body = await parseBody(req);
307
+ const result = apiInjectMessage(body, url.searchParams);
308
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
309
+ res.end(JSON.stringify(result));
310
+ }
311
+ // Multi-project management
312
+ else if (url.pathname === '/api/projects' && req.method === 'GET') {
313
+ res.writeHead(200, { 'Content-Type': 'application/json' });
314
+ res.end(JSON.stringify(apiProjects()));
315
+ }
316
+ else if (url.pathname === '/api/projects' && req.method === 'POST') {
317
+ const body = await parseBody(req);
318
+ const result = apiAddProject(body);
319
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
320
+ res.end(JSON.stringify(result));
321
+ }
322
+ else if (url.pathname === '/api/projects' && req.method === 'DELETE') {
323
+ const body = await parseBody(req);
324
+ const result = apiRemoveProject(body);
325
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
326
+ res.end(JSON.stringify(result));
327
+ }
328
+ else {
329
+ res.writeHead(404, { 'Content-Type': 'application/json' });
330
+ res.end(JSON.stringify({ error: 'Not found' }));
331
+ }
332
+ } catch (err) {
333
+ res.writeHead(500, { 'Content-Type': 'application/json' });
334
+ res.end(JSON.stringify({ error: err.message }));
335
+ }
336
+ });
337
+
338
+ server.on('error', (err) => {
339
+ if (err.code === 'EADDRINUSE') {
340
+ console.error(`\n Error: Port ${PORT} is already in use.`);
341
+ console.error(` Another dashboard may be running. Try:`);
342
+ console.error(` - Kill it: npx kill-port ${PORT}`);
343
+ console.error(` - Or use a different port: AGENT_BRIDGE_PORT=3001 npx let-them-talk dashboard\n`);
344
+ process.exit(1);
345
+ }
346
+ throw err;
347
+ });
348
+
349
+ server.listen(PORT, () => {
350
+ const dataDir = resolveDataDir();
351
+ console.log('');
352
+ console.log(' Let Them Talk - Agent Bridge Dashboard v2.0');
353
+ console.log(' ============================================');
354
+ console.log(' Dashboard: http://localhost:' + PORT);
355
+ console.log(' Data dir: ' + dataDir);
356
+ console.log(' Projects: ' + getProjects().length + ' registered');
357
+ console.log(' Polling: every 2s');
358
+ console.log('');
359
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "let-them-talk",
3
+ "version": "2.0.0",
4
+ "description": "MCP message broker + web dashboard for inter-agent communication. Let AI CLI agents talk to each other.",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "agent-bridge": "./cli.js",
8
+ "let-them-talk": "./cli.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node server.js",
12
+ "dashboard": "node dashboard.js"
13
+ },
14
+ "files": [
15
+ "server.js",
16
+ "dashboard.js",
17
+ "dashboard.html",
18
+ "cli.js"
19
+ ],
20
+ "keywords": [
21
+ "mcp",
22
+ "claude",
23
+ "claude-code",
24
+ "gemini-cli",
25
+ "codex-cli",
26
+ "agent",
27
+ "multi-agent",
28
+ "communication",
29
+ "message-broker",
30
+ "ai-agents",
31
+ "let-them-talk"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/Dekelelz/let-them-talk.git"
36
+ },
37
+ "homepage": "https://github.com/Dekelelz/let-them-talk",
38
+ "bugs": {
39
+ "url": "https://github.com/Dekelelz/let-them-talk/issues"
40
+ },
41
+ "author": "Dekelelz",
42
+ "license": "MIT",
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.0.0"
45
+ }
46
+ }