let-them-talk 3.2.3 → 3.3.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,122 @@
1
+ # Changelog
2
+
3
+ ## [3.0.0] - 2026-03-14
4
+
5
+ ### Added — Agent Profiles
6
+ - New tool: `update_profile` (display_name, avatar, bio, role)
7
+ - 12 built-in SVG robot avatar icons with hash-based defaults
8
+ - Profiles auto-created on register, persist across restarts
9
+ - Profile data shown in dashboard (avatars, role badges, profile popup)
10
+
11
+ ### Added — Agent Workspaces
12
+ - 3 new tools: `workspace_write`, `workspace_read`, `workspace_list`
13
+ - Per-agent key-value storage (50 keys max, 100KB per value)
14
+ - Agents can read anyone's workspace, write only their own
15
+ - Dashboard "Workspaces" tab with collapsible accordion UI
16
+
17
+ ### Added — Workflow Automation
18
+ - 3 new tools: `create_workflow`, `advance_workflow`, `workflow_status`
19
+ - Multi-step pipelines with auto-handoff to step assignees
20
+ - Dashboard "Workflows" tab with horizontal pipeline visualization
21
+ - Dashboard can advance/skip workflow steps
22
+
23
+ ### Added — Conversation Branching
24
+ - 3 new tools: `fork_conversation`, `switch_branch`, `list_branches`
25
+ - Fork at any message point with isolated branch history
26
+ - All message tools branch-aware (backward compatible — main branch uses existing files)
27
+ - Branch tabs in dashboard
28
+
29
+ ### Added — Plugin System
30
+ - Dynamic tool loading from `plugins/*.js` files
31
+ - Sandboxed execution with 30s timeout
32
+ - CLI: `npx let-them-talk plugin add/list/remove/enable/disable`
33
+ - Dashboard plugin cards with enable/disable toggles
34
+
35
+ ### Changed
36
+ - MCP tools: 17 → 27 + dynamic plugins
37
+ - Dashboard tabs: 2 → 4 (Messages, Tasks, Workspaces, Workflows)
38
+ - Branch-aware history API (`?branch=` query param)
39
+ - Version bump across all files (server, dashboard, CLI, package.json)
40
+
41
+ ## [2.5.0] - 2026-03-14
42
+
43
+ ### Added
44
+ - Task management system: `create_task`, `update_task`, `list_tasks` tools
45
+ - Kanban board in dashboard (Messages/Tasks toggle)
46
+ - Agent stats panel (sent/received/avg response time per agent)
47
+ - Shareable HTML export (/api/export endpoint)
48
+ - Export dropdown (HTML + Markdown formats)
49
+ - Conversation bookmarks (star messages, localStorage)
50
+ - Sound notification toggle (Web Audio API)
51
+ - Typing indicator for processing agents
52
+ - Connection quality display (SSE latency)
53
+ - Date separators between message groups
54
+ - Message grouping for consecutive same-sender messages
55
+ - Project auto-discover (scan nearby folders)
56
+ - Copy-to-clipboard prompts in onboarding
57
+ - Dynamic tab title with message count
58
+ - Dashboard footer with version
59
+
60
+ ### Security
61
+ - Path traversal fix in `share_file` (restricted to project dir)
62
+ - Path traversal fix in `?project=` param (validate against registered projects)
63
+ - 1MB message size limit on send/broadcast/handoff
64
+ - 1MB request body limit on dashboard POST endpoints
65
+ - XSS fix in HTML export (escape agent names)
66
+ - CORS restricted to localhost only (was wildcard)
67
+ - Dashboard binds to 127.0.0.1 only (was 0.0.0.0)
68
+ - Registration guard on `reset` tool
69
+ - Removed absolute file paths from share_file responses
70
+
71
+ ## [2.3.0] - 2026-03-14
72
+
73
+ ### Added
74
+ - `handoff` tool for structured work delegation
75
+ - `share_file` tool for sending file contents between agents
76
+ - `broadcast` tool for messaging all agents at once
77
+ - `get_summary` tool for conversation recaps
78
+ - Server-Sent Events for real-time dashboard updates
79
+ - `fs.watch()` on data directory with debounced SSE push
80
+ - Graceful SSE fallback to polling
81
+ - Handoff message rendering (purple banner)
82
+ - File share message rendering (file icon + size)
83
+
84
+ ## [2.1.0] - 2026-03-14
85
+
86
+ ### Added
87
+ - Multi-agent support (any name, not just A/B)
88
+ - `list_agents` tool with alive/dead status
89
+ - `listen` tool (blocks indefinitely, never times out)
90
+ - Conversation threading (`reply_to` + auto `thread_id`)
91
+ - Message acknowledgments (`ack_message` tool)
92
+ - Heartbeat system (10s interval, `last_activity` tracking)
93
+ - Agent status: active/sleeping/dead with idle time
94
+ - Listening status tracking (`listening_since`)
95
+ - Auto-compact messages.jsonl when >500 lines
96
+ - Auto-archive conversations before reset
97
+ - Context hints when conversation exceeds 50 messages
98
+ - Dead recipient warnings in `send_message`
99
+ - Message sequence numbers for ordering
100
+ - `pending_count` and `agents_online` in delivery responses
101
+ - 4 agent templates: pair, team, review, debate
102
+ - CLI: `npx let-them-talk templates` command
103
+ - CLI: `--template` flag for guided setup
104
+ - Multi-CLI support: Claude Code, Gemini CLI, Codex CLI
105
+ - `AGENT_BRIDGE_DATA_DIR` env var in MCP config
106
+
107
+ ### Fixed
108
+ - Heartbeat timer `.unref()` to prevent zombie processes
109
+ - Process exit cleanup (deregister agent on exit)
110
+ - Re-registration cleanup (old name removed)
111
+ - Stale byte offset recovery on file truncation
112
+
113
+ ## [2.0.0] - 2026-03-14
114
+
115
+ ### Added
116
+ - Initial release
117
+ - MCP server with stdio transport
118
+ - 6 tools: register, send_message, wait_for_reply, check_messages, get_history, reset
119
+ - Web dashboard with real-time monitoring
120
+ - Message injection from dashboard
121
+ - Dark theme UI with markdown rendering
122
+ - `.mcp.json` project-level configuration
package/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/let-them-talk.svg)](https://www.npmjs.com/package/let-them-talk)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
+ [![Discord](https://img.shields.io/discord/1482478651000885359?color=5865F2&label=Discord&logo=discord&logoColor=white)](https://discord.gg/6Y9YgkFNJP)
5
6
 
6
7
  **MCP server + web dashboard that lets AI CLI agents talk to each other.**
7
8
 
@@ -154,6 +155,22 @@ npx let-them-talk plugin disable <name> # Disable a plugin
154
155
  npx let-them-talk help # Show help
155
156
  ```
156
157
 
158
+ ## Updating
159
+
160
+ ```bash
161
+ # If using npx (recommended) — clear cache to get latest version
162
+ npx clear-npx-cache
163
+ npx let-them-talk init # Re-run to update MCP config paths
164
+
165
+ # If installed globally
166
+ npm update -g let-them-talk
167
+
168
+ # Check your version
169
+ npx let-them-talk help # Shows version in header
170
+ ```
171
+
172
+ After updating, restart your CLI terminals to pick up the new MCP server.
173
+
157
174
  ## Plugins
158
175
 
159
176
  Extend Let Them Talk with custom tools. Plugins are `.js` files in the `.agent-bridge/plugins/` directory.
package/SECURITY.md ADDED
@@ -0,0 +1,58 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ | Version | Supported |
6
+ | ------- | ------------------ |
7
+ | 3.x.x | Yes |
8
+ | 2.x.x | No |
9
+ | < 2.0 | No |
10
+
11
+ ## Reporting a Vulnerability
12
+
13
+ If you discover a security vulnerability in Let Them Talk, please report it responsibly.
14
+
15
+ **Do NOT open a public GitHub issue for security vulnerabilities.**
16
+
17
+ Instead, please email **security@dos-technology.com** or use [GitHub's private vulnerability reporting](https://github.com/Dekelelz/let-them-talk/security/advisories/new).
18
+
19
+ ### What to include
20
+
21
+ - Description of the vulnerability
22
+ - Steps to reproduce
23
+ - Potential impact
24
+ - Suggested fix (if any)
25
+
26
+ ### Response timeline
27
+
28
+ - **Acknowledgment**: Within 48 hours
29
+ - **Initial assessment**: Within 1 week
30
+ - **Fix release**: As soon as possible, typically within 2 weeks
31
+
32
+ ## Security Model
33
+
34
+ Let Them Talk is a **local message broker** — it passes text messages between CLI terminals via shared files on your local machine.
35
+
36
+ ### What it does NOT do
37
+
38
+ - Does not give agents filesystem access (they already have it via their CLI)
39
+ - Does not expose anything to the internet (dashboard binds to `127.0.0.1` only)
40
+ - Does not store or transmit API keys
41
+ - Does not run any cloud services
42
+ - Does not execute remote code
43
+
44
+ ### Built-in protections
45
+
46
+ - **CORS restriction** — dashboard only accepts requests from localhost
47
+ - **XSS prevention** — all user inputs are escaped before rendering
48
+ - **Path traversal protection** — agents cannot read files outside the project directory
49
+ - **Symlink protection** — follows symlinks and validates the real path
50
+ - **Origin enforcement** — POST/DELETE requests require valid localhost origin
51
+ - **SSE connection limits** — prevents connection exhaustion
52
+ - **Input validation** — agent names, branch names, and file paths are validated
53
+ - **Message size limits** — 1MB max per message
54
+ - **Plugin sandboxing** — plugins run with a 30-second timeout
55
+
56
+ ### LAN mode
57
+
58
+ When using `--lan` mode, the dashboard is exposed to your local network only. It is never accessible from the internet.
package/cli.js CHANGED
@@ -8,7 +8,7 @@ const command = process.argv[2];
8
8
 
9
9
  function printUsage() {
10
10
  console.log(`
11
- Let Them Talk — Agent Bridge v3.2.0
11
+ Let Them Talk — Agent Bridge v3.3.0
12
12
  MCP message broker for inter-agent communication
13
13
  Supports: Claude Code, Gemini CLI, Codex CLI
14
14
 
@@ -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.html CHANGED
@@ -2471,7 +2471,7 @@
2471
2471
  </div>
2472
2472
  </div>
2473
2473
  <div class="app-footer">
2474
- <span>Let Them Talk v3.2.0</span>
2474
+ <span>Let Them Talk v3.3.0</span>
2475
2475
  </div>
2476
2476
  <div class="profile-popup" id="profile-popup" onclick="event.stopPropagation()">
2477
2477
  <div class="profile-popup-header">
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.3",
3
+ "version": "3.3.1",
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": {
@@ -22,7 +22,9 @@
22
22
  "cli.js",
23
23
  "templates/",
24
24
  "logo.png",
25
- "LICENSE"
25
+ "LICENSE",
26
+ "SECURITY.md",
27
+ "CHANGELOG.md"
26
28
  ],
27
29
  "keywords": [
28
30
  "mcp",
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
 
@@ -1977,7 +2021,7 @@ async function main() {
1977
2021
  loadPlugins();
1978
2022
  const transport = new StdioServerTransport();
1979
2023
  await server.connect(transport);
1980
- console.error('Agent Bridge MCP server v3.2.0 running (' + (27 + loadedPlugins.length) + ' tools)');
2024
+ console.error('Agent Bridge MCP server v3.3.1 running (' + (27 + loadedPlugins.length) + ' tools)');
1981
2025
  }
1982
2026
 
1983
2027
  main().catch(console.error);