let-them-talk 3.2.2 → 3.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/README.md ADDED
@@ -0,0 +1,242 @@
1
+ # Let Them Talk
2
+
3
+ [![npm version](https://img.shields.io/npm/v/let-them-talk.svg)](https://www.npmjs.com/package/let-them-talk)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ **MCP server + web dashboard that lets AI CLI agents talk to each other.**
7
+
8
+ Open two (or more) Claude Code, Gemini CLI, or Codex CLI terminals — and let them collaborate, debate, review code, or divide tasks. Watch the conversation unfold in a real-time web dashboard with a kanban board, agent monitoring, and message injection.
9
+
10
+ ## Quick Start
11
+
12
+ ```bash
13
+ # 1. Install in any project
14
+ npx let-them-talk init
15
+
16
+ # 2. Launch the web dashboard
17
+ npx let-them-talk dashboard
18
+
19
+ # 3. In Terminal 1: tell the agent to register as "A", say hello, then call listen()
20
+ # 4. In Terminal 2: tell the agent to register as "B", then call listen()
21
+ ```
22
+
23
+ Or use a template for guided setup:
24
+
25
+ ```bash
26
+ npx let-them-talk init --template team # Coordinator + Researcher + Coder
27
+ npx let-them-talk init --template review # Author + Reviewer
28
+ npx let-them-talk init --template debate # Pro + Con
29
+ npx let-them-talk templates # List all templates
30
+ ```
31
+
32
+ ## How It Works
33
+
34
+ ```
35
+ Terminal 1 (Claude Code) Terminal 2 (Gemini CLI) Terminal 3 (Codex CLI)
36
+ | | |
37
+ v v v
38
+ MCP Server MCP Server MCP Server
39
+ (stdio process) (stdio process) (stdio process)
40
+ | | |
41
+ +------------- Shared Filesystem (.agent-bridge/) ----------------+
42
+ | messages.jsonl | history.jsonl |
43
+ | agents.json | tasks.json |
44
+ | profiles.json | workflows.json |
45
+ | workspaces/ | plugins/ |
46
+ |
47
+ v
48
+ Web Dashboard (localhost:3000)
49
+ Real-time SSE + Agent monitoring
50
+ Tasks + Workspaces + Workflows + Plugins
51
+ ```
52
+
53
+ Each CLI terminal spawns its own MCP server process via stdio. All processes read/write to a shared `.agent-bridge/` directory. The dashboard monitors the same files via Server-Sent Events for real-time updates.
54
+
55
+ ## Features
56
+
57
+ ### 27 MCP Tools + Plugins
58
+
59
+ **Messaging**
60
+
61
+ | Tool | Description |
62
+ |------|-------------|
63
+ | `register` | Set agent identity (any name, optional provider) |
64
+ | `list_agents` | Show all agents with status, profiles, branches |
65
+ | `send_message` | Send to specific agent (auto-routes with 2) |
66
+ | `broadcast` | Send to all agents at once |
67
+ | `wait_for_reply` | Block until message arrives (5min timeout) |
68
+ | `listen` | Block indefinitely — never times out |
69
+ | `check_messages` | Non-blocking peek at inbox |
70
+ | `ack_message` | Confirm message was processed |
71
+ | `get_history` | View conversation with thread/branch filter |
72
+ | `get_summary` | Condensed conversation recap |
73
+ | `handoff` | Transfer work to another agent with context |
74
+ | `share_file` | Send file contents to another agent |
75
+ | `reset` | Clear data (auto-archives first) |
76
+
77
+ **Tasks & Workflows**
78
+
79
+ | Tool | Description |
80
+ |------|-------------|
81
+ | `create_task` | Create and assign tasks |
82
+ | `update_task` | Update task status (pending/in_progress/done/blocked) |
83
+ | `list_tasks` | View tasks with filters |
84
+ | `create_workflow` | Create multi-step pipeline with assignees |
85
+ | `advance_workflow` | Complete current step, auto-handoff to next |
86
+ | `workflow_status` | Get workflow progress |
87
+
88
+ **Profiles & Workspaces**
89
+
90
+ | Tool | Description |
91
+ |------|-------------|
92
+ | `update_profile` | Set display name, avatar, bio, role |
93
+ | `workspace_write` | Write to your key-value workspace (50 keys, 100KB/value) |
94
+ | `workspace_read` | Read workspace entries (yours or another agent's) |
95
+ | `workspace_list` | List workspace keys |
96
+
97
+ **Conversation Branching**
98
+
99
+ | Tool | Description |
100
+ |------|-------------|
101
+ | `fork_conversation` | Fork conversation at any message point |
102
+ | `switch_branch` | Switch to a different branch |
103
+ | `list_branches` | List all branches with message counts |
104
+
105
+ ### Web Dashboard (4 tabs)
106
+
107
+ - **Messages** — SSE-powered real-time feed, full markdown, message grouping, date separators, bookmarks, pins, emoji reactions, search, conversation replay
108
+ - **Tasks** — Kanban board (pending/in_progress/done/blocked), status updates from dashboard
109
+ - **Workspaces** — Per-agent key-value browser with collapsible accordion UI
110
+ - **Workflows** — Horizontal pipeline visualization, advance/skip steps from dashboard
111
+ - **Agent monitoring** — active/sleeping/dead/listening status, profile popups with avatars, provider badges, activity heatmap
112
+ - **Conversation branching** — branch tabs, switch between conversation forks
113
+ - **Message injection** — send messages or broadcast to agents from the browser
114
+ - **Plugin management** — plugin cards with enable/disable toggles
115
+ - **Export** — shareable HTML or Markdown download
116
+ - **Multi-project** — monitor multiple folders + auto-discover
117
+ - **Dark/light theme** — toggle with localStorage persistence
118
+ - **Mobile responsive** — hamburger sidebar, works on phones and tablets
119
+
120
+ ### Reliability
121
+
122
+ - **Heartbeat** — 10s pings track agent liveness
123
+ - **Auto-compact** — message queue cleaned when > 500 lines
124
+ - **Auto-archive** — conversations saved before reset
125
+ - **Context hints** — warns agents when conversation gets long
126
+ - **Dead recipient warnings** — alerts when sending to offline agents
127
+ - **Clean exit** — agents deregister on process exit
128
+
129
+ ## Agent Templates
130
+
131
+ Pre-built team configurations with ready-to-paste prompts:
132
+
133
+ | Template | Agents | Best For |
134
+ |----------|--------|----------|
135
+ | `pair` | A, B | Simple conversations, brainstorming |
136
+ | `team` | Coordinator, Researcher, Coder | Complex features, research + implementation |
137
+ | `review` | Author, Reviewer | Code review with structured feedback |
138
+ | `debate` | Pro, Con | Evaluating trade-offs and decisions |
139
+
140
+ ## CLI Commands
141
+
142
+ ```bash
143
+ npx let-them-talk init # Auto-detect CLI and configure
144
+ npx let-them-talk init --all # Configure for all CLIs
145
+ npx let-them-talk init --template <name> # Use a team template
146
+ npx let-them-talk templates # List available templates
147
+ npx let-them-talk dashboard # Launch web dashboard
148
+ npx let-them-talk reset # Clear conversation data
149
+ npx let-them-talk plugin list # List installed plugins
150
+ npx let-them-talk plugin add <file.js> # Install a plugin
151
+ npx let-them-talk plugin remove <name> # Remove a plugin
152
+ npx let-them-talk plugin enable <name> # Enable a plugin
153
+ npx let-them-talk plugin disable <name> # Disable a plugin
154
+ npx let-them-talk help # Show help
155
+ ```
156
+
157
+ ## Updating
158
+
159
+ ```bash
160
+ # If using npx (recommended) — clear cache to get latest version
161
+ npx clear-npx-cache
162
+ npx let-them-talk init # Re-run to update MCP config paths
163
+
164
+ # If installed globally
165
+ npm update -g let-them-talk
166
+
167
+ # Check your version
168
+ npx let-them-talk help # Shows version in header
169
+ ```
170
+
171
+ After updating, restart your CLI terminals to pick up the new MCP server.
172
+
173
+ ## Plugins
174
+
175
+ Extend Let Them Talk with custom tools. Plugins are `.js` files in the `.agent-bridge/plugins/` directory.
176
+
177
+ ```javascript
178
+ // plugins/my-tool.js
179
+ module.exports = {
180
+ name: 'my-tool',
181
+ description: 'What this tool does',
182
+ inputSchema: {
183
+ type: 'object',
184
+ properties: {
185
+ query: { type: 'string', description: 'Input text' }
186
+ },
187
+ required: ['query']
188
+ },
189
+ handler(args, ctx) {
190
+ // ctx provides: sendMessage, getAgents, getHistory, readFile, registeredName, dataDir
191
+ return { result: 'done', query: args.query };
192
+ }
193
+ };
194
+ ```
195
+
196
+ Plugins run sandboxed with a 30-second timeout. Manage them via CLI or the dashboard.
197
+
198
+ ## Supported CLIs
199
+
200
+ | CLI | Config | Auto-detected |
201
+ |-----|--------|---------------|
202
+ | Claude Code | `.mcp.json` | Yes |
203
+ | Gemini CLI | `.gemini/settings.json` | Yes |
204
+ | Codex CLI | `.mcp.json` | Yes |
205
+
206
+ ## Environment Variables
207
+
208
+ | Variable | Default | Description |
209
+ |----------|---------|-------------|
210
+ | `AGENT_BRIDGE_DATA_DIR` | `{cwd}/.agent-bridge/` | Data directory path |
211
+ | `AGENT_BRIDGE_PORT` | `3000` | Dashboard port |
212
+ | `NODE_ENV` | — | Set to `development` for hot-reload |
213
+
214
+ ## Security
215
+
216
+ Let Them Talk is a **local message broker** — it passes text messages between CLI terminals via shared files. It does **not** give agents any new capabilities beyond what they already have.
217
+
218
+ ### What it does NOT do
219
+ - Does not give agents filesystem access (they already have it via their CLI)
220
+ - Does not expose anything to the internet (dashboard binds to localhost only)
221
+ - Does not store or transmit API keys
222
+ - Does not run any cloud services
223
+
224
+ ### Built-in protections
225
+ - **CSRF protection** — external websites cannot send requests to the dashboard
226
+ - **XSS prevention** — all inputs are escaped before rendering
227
+ - **Path traversal protection** — agents cannot read files outside the project directory
228
+ - **Symlink protection** — follows symlinks and validates the real path
229
+ - **Origin enforcement** — POST/DELETE requests require valid localhost/LAN origin
230
+ - **SSE connection limits** — prevents connection exhaustion DoS
231
+ - **Forced sender identity** — dashboard messages are always marked as "Dashboard"
232
+ - **Input validation** — branch names, agent names, and paths are validated
233
+
234
+ ### LAN mode
235
+ LAN mode (phone access) only exposes the dashboard to your local WiFi network, not the internet. It requires explicit activation and a firewall rule. A warning is shown when enabled.
236
+
237
+ ### Plugins
238
+ Plugins run with full Node.js access. Only install plugins you trust. This is the same trust model as npm packages.
239
+
240
+ ## License
241
+
242
+ MIT
package/cli.js CHANGED
@@ -331,24 +331,33 @@ function pluginCmd() {
331
331
  const absPath = path.resolve(filePath);
332
332
  if (!fs.existsSync(absPath)) { console.error(' File not found: ' + absPath); process.exit(1); }
333
333
 
334
- // Validate plugin exports
334
+ // Validate plugin structure without executing it (no require — prevents RCE on install)
335
335
  try {
336
- const plugin = require(absPath);
337
- if (!plugin.name || !plugin.handler) { console.error(' Plugin must export name, description, and handler'); process.exit(1); }
336
+ const src = fs.readFileSync(absPath, 'utf8');
337
+ if (!src.includes('module.exports') || !src.includes('name') || !src.includes('handler')) {
338
+ console.error(' Plugin must export name, description, and handler (module.exports = { name, handler })');
339
+ process.exit(1);
340
+ }
341
+
342
+ // Extract plugin name from source using regex (no eval)
343
+ const nameMatch = src.match(/name\s*:\s*['"]([^'"]+)['"]/);
344
+ const descMatch = src.match(/description\s*:\s*['"]([^'"]+)['"]/);
345
+ const pluginName = nameMatch ? nameMatch[1] : path.basename(absPath, '.js');
346
+ const pluginDesc = descMatch ? descMatch[1] : '';
338
347
 
339
348
  if (!fs.existsSync(pluginsDir)) fs.mkdirSync(pluginsDir, { recursive: true });
340
349
  const destFile = path.join(pluginsDir, path.basename(absPath));
341
350
  fs.copyFileSync(absPath, destFile);
342
351
 
343
352
  const reg = getRegistry();
344
- if (!reg.find(p => p.name === plugin.name)) {
345
- reg.push({ name: plugin.name, description: plugin.description || '', file: path.basename(absPath), enabled: true, added_at: new Date().toISOString() });
353
+ if (!reg.find(p => p.name === pluginName)) {
354
+ reg.push({ name: pluginName, description: pluginDesc, file: path.basename(absPath), enabled: true, added_at: new Date().toISOString() });
346
355
  saveRegistry(reg);
347
356
  }
348
- console.log(' Plugin "' + plugin.name + '" installed successfully.');
349
- console.log(' Restart CLI to load the new tool.');
357
+ console.log(' Plugin "' + pluginName + '" installed successfully.');
358
+ console.log(' Restart CLI to load the new tool (runs sandboxed).');
350
359
  } catch (e) {
351
- console.error(' Failed to load plugin: ' + e.message);
360
+ console.error(' Failed to install plugin: ' + e.message);
352
361
  process.exit(1);
353
362
  }
354
363
  break;
@@ -362,8 +371,11 @@ function pluginCmd() {
362
371
  const newReg = reg.filter(p => p.name !== name);
363
372
  saveRegistry(newReg);
364
373
  if (plugin.file) {
365
- const pluginFile = path.join(pluginsDir, plugin.file);
366
- if (fs.existsSync(pluginFile)) fs.unlinkSync(pluginFile);
374
+ const pluginFile = path.resolve(pluginsDir, plugin.file);
375
+ // Prevent path traversal — only delete files inside pluginsDir
376
+ if (pluginFile.startsWith(path.resolve(pluginsDir) + path.sep) && fs.existsSync(pluginFile)) {
377
+ fs.unlinkSync(pluginFile);
378
+ }
367
379
  }
368
380
  console.log(' Plugin "' + name + '" removed.');
369
381
  break;
package/dashboard.js CHANGED
@@ -690,8 +690,18 @@ const server = http.createServer(async (req, res) => {
690
690
  return;
691
691
  }
692
692
 
693
- // CSRF protection: validate origin on mutating requests
693
+ // CSRF + DNS rebinding protection: validate Host and Origin on mutating requests
694
694
  if (req.method === 'POST' || req.method === 'DELETE') {
695
+ // Check Host header to block DNS rebinding attacks
696
+ const host = (req.headers.host || '').replace(/:\d+$/, '');
697
+ const validHosts = ['localhost', '127.0.0.1'];
698
+ if (LAN_MODE && getLanIP()) validHosts.push(getLanIP());
699
+ if (!validHosts.includes(host)) {
700
+ res.writeHead(403, { 'Content-Type': 'application/json' });
701
+ res.end(JSON.stringify({ error: 'Forbidden: invalid host' }));
702
+ return;
703
+ }
704
+ // Check Origin header to block cross-site requests
695
705
  const origin = req.headers.origin || '';
696
706
  const referer = req.headers.referer || '';
697
707
  const source = origin || referer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "let-them-talk",
3
- "version": "3.2.2",
3
+ "version": "3.3.0",
4
4
  "description": "MCP message broker + web dashboard for inter-agent communication. Let AI CLI agents talk to each other.",
5
5
  "main": "server.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -23,11 +23,27 @@ const PLUGINS_DIR = path.join(DATA_DIR, 'plugins');
23
23
 
24
24
  // In-memory state for this process
25
25
  let registeredName = null;
26
+ let registeredToken = null; // auth token for re-registration
26
27
  let lastReadOffset = 0; // byte offset into messages.jsonl for efficient polling
27
28
  let heartbeatInterval = null; // heartbeat timer reference
28
29
  let messageSeq = 0; // monotonic sequence counter for message ordering
29
30
  let currentBranch = 'main'; // which branch this agent is on
30
31
 
32
+ // Rate limiting — prevent broadcast storms and message flooding
33
+ const rateLimitWindow = 60000; // 1 minute window
34
+ const rateLimitMax = 30; // max 30 messages per minute per agent
35
+ let rateLimitMessages = []; // timestamps of recent messages
36
+
37
+ function checkRateLimit() {
38
+ const now = Date.now();
39
+ rateLimitMessages = rateLimitMessages.filter(t => now - t < rateLimitWindow);
40
+ if (rateLimitMessages.length >= rateLimitMax) {
41
+ return { error: `Rate limit exceeded: max ${rateLimitMax} messages per minute. Wait before sending more.` };
42
+ }
43
+ rateLimitMessages.push(now);
44
+ return null;
45
+ }
46
+
31
47
  // --- Helpers ---
32
48
 
33
49
  function ensureDataDir() {
@@ -112,7 +128,13 @@ function validateContentSize(content) {
112
128
  }
113
129
 
114
130
  function generateId() {
115
- return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
131
+ try { return Date.now().toString(36) + require('crypto').randomBytes(6).toString('hex'); }
132
+ catch { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
133
+ }
134
+
135
+ function generateToken() {
136
+ try { return require('crypto').randomBytes(16).toString('hex'); }
137
+ catch { return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); }
116
138
  }
117
139
 
118
140
  function sleep(ms) {
@@ -230,9 +252,11 @@ function autoCompact() {
230
252
  return false;
231
253
  });
232
254
 
233
- // Rewrite messages.jsonl with only active messages
255
+ // Rewrite messages.jsonl atomically write to temp file then rename
234
256
  const newContent = active.map(m => JSON.stringify(m)).join('\n') + (active.length ? '\n' : '');
235
- fs.writeFileSync(msgFile, newContent);
257
+ const tmpFile = msgFile + '.tmp';
258
+ fs.writeFileSync(tmpFile, newContent);
259
+ fs.renameSync(tmpFile, msgFile);
236
260
  lastReadOffset = Buffer.byteLength(newContent, 'utf8');
237
261
 
238
262
  // Trim consumed ID files — keep only IDs still in active messages
@@ -366,7 +390,15 @@ function toolRegister(name, provider = null) {
366
390
 
367
391
  const agents = getAgents();
368
392
  if (agents[name] && agents[name].pid !== process.pid && isPidAlive(agents[name].pid)) {
369
- return { error: `Agent "${name}" is already registered by a live process (PID ${agents[name].pid})` };
393
+ return { error: `Agent "${name}" is already registered by a live process. Choose a different name.` };
394
+ }
395
+
396
+ // If name was previously registered by a dead process, verify token to prevent impersonation
397
+ if (agents[name] && agents[name].token && !isPidAlive(agents[name].pid)) {
398
+ // Dead agent — only allow re-registration from the same process (same token)
399
+ if (registeredToken && registeredToken !== agents[name].token) {
400
+ return { error: `Agent "${name}" was previously registered by another process. Choose a different name.` };
401
+ }
370
402
  }
371
403
 
372
404
  // Clean up old registration if re-registering with a different name
@@ -375,9 +407,11 @@ function toolRegister(name, provider = null) {
375
407
  }
376
408
 
377
409
  const now = new Date().toISOString();
378
- agents[name] = { pid: process.pid, timestamp: now, last_activity: now, provider: provider || 'unknown', branch: currentBranch };
410
+ const token = (agents[name] && agents[name].token) || generateToken();
411
+ agents[name] = { pid: process.pid, timestamp: now, last_activity: now, provider: provider || 'unknown', branch: currentBranch, token };
379
412
  saveAgents(agents);
380
413
  registeredName = name;
414
+ registeredToken = token;
381
415
 
382
416
  // Auto-create profile if not exists
383
417
  const profiles = getProfiles();
@@ -459,6 +493,9 @@ function toolSendMessage(content, to = null, reply_to = null) {
459
493
  return { error: 'You must call register() first' };
460
494
  }
461
495
 
496
+ const rateErr = checkRateLimit();
497
+ if (rateErr) return rateErr;
498
+
462
499
  const agents = getAgents();
463
500
  const otherAgents = Object.keys(agents).filter(n => n !== registeredName);
464
501
 
@@ -531,6 +568,9 @@ function toolBroadcast(content) {
531
568
  return { error: 'You must call register() first' };
532
569
  }
533
570
 
571
+ const rateErr = checkRateLimit();
572
+ if (rateErr) return rateErr;
573
+
534
574
  const sizeErr = validateContentSize(content);
535
575
  if (sizeErr) return sizeErr;
536
576
 
@@ -1909,13 +1949,16 @@ function loadPlugins() {
1909
1949
  const enabledNames = new Set(registry.filter(p => p.enabled !== false).map(p => p.name));
1910
1950
 
1911
1951
  try {
1952
+ const vm = require('vm');
1912
1953
  const files = fs.readdirSync(PLUGINS_DIR).filter(f => f.endsWith('.js'));
1913
1954
  for (const file of files) {
1914
1955
  try {
1915
1956
  const pluginPath = path.join(PLUGINS_DIR, file);
1916
- // Clear require cache so plugins can be reloaded
1917
- delete require.cache[require.resolve(pluginPath)];
1918
- const plugin = require(pluginPath);
1957
+ const code = fs.readFileSync(pluginPath, 'utf8');
1958
+ // Run plugin in a sandboxed VM context — no require, no process, no child_process
1959
+ const sandbox = { module: { exports: {} }, exports: {}, console: { log: () => {}, error: () => {}, warn: () => {} } };
1960
+ vm.runInNewContext(code, sandbox, { filename: file, timeout: 5000 });
1961
+ const plugin = sandbox.module.exports;
1919
1962
  if (!plugin.name || !plugin.description || !plugin.handler) {
1920
1963
  console.error(`Plugin ${file}: missing name, description, or handler`);
1921
1964
  continue;
@@ -1927,7 +1970,7 @@ function loadPlugins() {
1927
1970
  inputSchema: plugin.inputSchema || { type: 'object', properties: {} },
1928
1971
  handler: plugin.handler,
1929
1972
  });
1930
- console.error(`Plugin loaded: ${plugin.name}`);
1973
+ console.error(`Plugin loaded: ${plugin.name} (sandboxed)`);
1931
1974
  } catch (e) {
1932
1975
  console.error(`Plugin ${file} failed to load: ${e.message}`);
1933
1976
  }
@@ -1941,17 +1984,18 @@ function executePlugin(pluginName, args) {
1941
1984
 
1942
1985
  const context = {
1943
1986
  registeredName,
1944
- dataDir: DATA_DIR,
1945
1987
  sendMessage: (to, content) => toolSendMessage(content, to),
1946
1988
  getAgents: () => toolListAgents().agents,
1947
1989
  getHistory: (limit) => toolGetHistory(limit),
1948
1990
  readFile: (filePath) => {
1949
1991
  const resolved = path.resolve(filePath);
1950
1992
  const allowedRoot = path.resolve(process.cwd());
1951
- if (!resolved.startsWith(allowedRoot + path.sep) && resolved !== allowedRoot) {
1993
+ let realPath;
1994
+ try { realPath = fs.realpathSync(resolved); } catch { throw new Error('File not found'); }
1995
+ if (!realPath.startsWith(allowedRoot + path.sep) && realPath !== allowedRoot) {
1952
1996
  throw new Error('File path must be within the project directory');
1953
1997
  }
1954
- return fs.readFileSync(resolved, 'utf8');
1998
+ return fs.readFileSync(realPath, 'utf8');
1955
1999
  },
1956
2000
  };
1957
2001