let-them-talk 3.4.1 → 3.4.3
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 +38 -0
- package/LICENSE +1 -1
- package/cli.js +2 -125
- package/dashboard.html +71 -139
- package/dashboard.js +66 -33
- package/package.json +1 -1
- package/server.js +4 -107
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.4.3] - 2026-03-15
|
|
4
|
+
|
|
5
|
+
### Removed — Plugin System
|
|
6
|
+
- Removed the entire plugin system (`vm.runInNewContext` sandbox, plugin CLI commands, dashboard plugin UI)
|
|
7
|
+
- **Why:** Plugins were an unnecessary attack surface. Node.js `vm` is not a security sandbox — plugins could escape and execute arbitrary OS commands. CLI terminals (Claude Code, Gemini, Codex) have their own extension systems, making our plugins redundant.
|
|
8
|
+
- `npx let-them-talk plugin` now shows a deprecation notice
|
|
9
|
+
- MCP tools reduced from 27 + plugins to 27 (all core tools remain)
|
|
10
|
+
- ~200 lines of code removed from server.js, cli.js, dashboard.js, dashboard.html
|
|
11
|
+
|
|
12
|
+
## [3.4.2] - 2026-03-15
|
|
13
|
+
|
|
14
|
+
### Security — CSRF Protection
|
|
15
|
+
- Required `X-LTT-Request` custom header on all POST/PUT/DELETE requests
|
|
16
|
+
- `lttFetch` wrapper in dashboard automatically includes the header
|
|
17
|
+
- Malicious cross-origin pages cannot set custom headers without CORS preflight approval
|
|
18
|
+
- Removed wildcard `Access-Control-Allow-Origin: *` in LAN mode — now uses explicit trusted origins only
|
|
19
|
+
- Empty Origin/Referer no longer auto-trusted — requires custom header as minimum protection
|
|
20
|
+
|
|
21
|
+
### Security — LAN Auth Token
|
|
22
|
+
- Auto-generated 32-char hex token when LAN mode is enabled
|
|
23
|
+
- Token required for all non-localhost requests (via `?token=` query param or `X-LTT-Token` header)
|
|
24
|
+
- Token included in QR code URL — phone scans and it just works
|
|
25
|
+
- Token displayed in phone access modal with explanation
|
|
26
|
+
- New token generated each time LAN mode is toggled on
|
|
27
|
+
- Token persists across server restarts via `.lan-token` file
|
|
28
|
+
- Localhost access never requires a token
|
|
29
|
+
|
|
30
|
+
### Security — Content Security Policy
|
|
31
|
+
- CSP header added to dashboard HTML response
|
|
32
|
+
- `script-src 'unsafe-inline'` for inline handlers, blocks `eval()` and external scripts
|
|
33
|
+
- `connect-src 'self'` restricts API calls to same origin
|
|
34
|
+
- `font-src`, `style-src`, `img-src` scoped to required sources only
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
- CSRF brace imbalance that trapped GET handlers inside POST-only block
|
|
38
|
+
- LAN token not forwarded from phone URL to API calls and SSE
|
|
39
|
+
- Redundant nested origin check collapsed to single condition
|
|
40
|
+
|
|
3
41
|
## [3.4.1] - 2026-03-15
|
|
4
42
|
|
|
5
43
|
### Added
|
package/LICENSE
CHANGED
|
@@ -6,7 +6,7 @@ License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
|
|
6
6
|
Parameters
|
|
7
7
|
|
|
8
8
|
Licensor: Dekelelz
|
|
9
|
-
Licensed Work: Let Them Talk v3.4.
|
|
9
|
+
Licensed Work: Let Them Talk v3.4.3
|
|
10
10
|
The Licensed Work is (c) 2024-2026 Dekelelz.
|
|
11
11
|
Additional Use Grant: You may make use of the Licensed Work, provided that
|
|
12
12
|
you may not use the Licensed Work for a Commercial
|
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.4.
|
|
11
|
+
Let Them Talk — Agent Bridge v3.4.3
|
|
12
12
|
MCP message broker for inter-agent communication
|
|
13
13
|
Supports: Claude Code, Gemini CLI, Codex CLI
|
|
14
14
|
|
|
@@ -23,11 +23,6 @@ function printUsage() {
|
|
|
23
23
|
npx let-them-talk dashboard Launch the web dashboard (http://localhost:3000)
|
|
24
24
|
npx let-them-talk dashboard --lan Launch dashboard accessible on LAN (phone/tablet)
|
|
25
25
|
npx let-them-talk reset Clear all conversation data
|
|
26
|
-
npx let-them-talk plugin list List installed plugins
|
|
27
|
-
npx let-them-talk plugin add <file> Install a plugin from a .js file
|
|
28
|
-
npx let-them-talk plugin remove <n> Remove a plugin by name
|
|
29
|
-
npx let-them-talk plugin enable <n> Enable a plugin
|
|
30
|
-
npx let-them-talk plugin disable <n> Disable a plugin
|
|
31
26
|
npx let-them-talk msg <agent> <text> Send a message to an agent
|
|
32
27
|
npx let-them-talk status Show active agents and message count
|
|
33
28
|
npx let-them-talk help Show this help message
|
|
@@ -293,124 +288,6 @@ function showTemplate(templateName) {
|
|
|
293
288
|
}
|
|
294
289
|
}
|
|
295
290
|
|
|
296
|
-
function pluginCmd() {
|
|
297
|
-
const subCmd = process.argv[3];
|
|
298
|
-
const dataDir = process.env.AGENT_BRIDGE_DATA_DIR || path.join(process.cwd(), '.agent-bridge');
|
|
299
|
-
const pluginsDir = path.join(dataDir, 'plugins');
|
|
300
|
-
const pluginsFile = path.join(dataDir, 'plugins.json');
|
|
301
|
-
|
|
302
|
-
function getRegistry() {
|
|
303
|
-
if (!fs.existsSync(pluginsFile)) return [];
|
|
304
|
-
try { return JSON.parse(fs.readFileSync(pluginsFile, 'utf8')); } catch { return []; }
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function saveRegistry(reg) {
|
|
308
|
-
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
309
|
-
fs.writeFileSync(pluginsFile, JSON.stringify(reg, null, 2));
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
switch (subCmd) {
|
|
313
|
-
case 'list': {
|
|
314
|
-
const plugins = getRegistry();
|
|
315
|
-
if (!plugins.length) {
|
|
316
|
-
console.log(' No plugins installed.');
|
|
317
|
-
console.log(' Install with: npx let-them-talk plugin add <file.js>');
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
console.log('');
|
|
321
|
-
console.log(' Installed Plugins');
|
|
322
|
-
console.log(' =================');
|
|
323
|
-
for (const p of plugins) {
|
|
324
|
-
const status = p.enabled !== false ? 'enabled' : 'disabled';
|
|
325
|
-
console.log(' ' + p.name.padEnd(20) + ' ' + status.padEnd(10) + ' ' + (p.description || ''));
|
|
326
|
-
}
|
|
327
|
-
console.log('');
|
|
328
|
-
break;
|
|
329
|
-
}
|
|
330
|
-
case 'add': {
|
|
331
|
-
const filePath = process.argv[4];
|
|
332
|
-
if (!filePath) { console.error(' Usage: npx let-them-talk plugin add <file.js>'); process.exit(1); }
|
|
333
|
-
const absPath = path.resolve(filePath);
|
|
334
|
-
if (!fs.existsSync(absPath)) { console.error(' File not found: ' + absPath); process.exit(1); }
|
|
335
|
-
|
|
336
|
-
// Validate plugin structure without executing it (no require — prevents RCE on install)
|
|
337
|
-
try {
|
|
338
|
-
const src = fs.readFileSync(absPath, 'utf8');
|
|
339
|
-
if (!src.includes('module.exports') || !src.includes('name') || !src.includes('handler')) {
|
|
340
|
-
console.error(' Plugin must export name, description, and handler (module.exports = { name, handler })');
|
|
341
|
-
process.exit(1);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Extract plugin name from source using regex (no eval)
|
|
345
|
-
const nameMatch = src.match(/name\s*:\s*['"]([^'"]+)['"]/);
|
|
346
|
-
const descMatch = src.match(/description\s*:\s*['"]([^'"]+)['"]/);
|
|
347
|
-
const pluginName = nameMatch ? nameMatch[1] : path.basename(absPath, '.js');
|
|
348
|
-
const pluginDesc = descMatch ? descMatch[1] : '';
|
|
349
|
-
|
|
350
|
-
if (!fs.existsSync(pluginsDir)) fs.mkdirSync(pluginsDir, { recursive: true });
|
|
351
|
-
const destFile = path.join(pluginsDir, path.basename(absPath));
|
|
352
|
-
fs.copyFileSync(absPath, destFile);
|
|
353
|
-
|
|
354
|
-
const reg = getRegistry();
|
|
355
|
-
if (!reg.find(p => p.name === pluginName)) {
|
|
356
|
-
reg.push({ name: pluginName, description: pluginDesc, file: path.basename(absPath), enabled: true, added_at: new Date().toISOString() });
|
|
357
|
-
saveRegistry(reg);
|
|
358
|
-
}
|
|
359
|
-
console.log(' Plugin "' + pluginName + '" installed successfully.');
|
|
360
|
-
console.log(' Restart CLI to load the new tool (runs sandboxed).');
|
|
361
|
-
} catch (e) {
|
|
362
|
-
console.error(' Failed to install plugin: ' + e.message);
|
|
363
|
-
process.exit(1);
|
|
364
|
-
}
|
|
365
|
-
break;
|
|
366
|
-
}
|
|
367
|
-
case 'remove': {
|
|
368
|
-
const name = process.argv[4];
|
|
369
|
-
if (!name) { console.error(' Usage: npx let-them-talk plugin remove <name>'); process.exit(1); }
|
|
370
|
-
const reg = getRegistry();
|
|
371
|
-
const plugin = reg.find(p => p.name === name);
|
|
372
|
-
if (!plugin) { console.error(' Plugin not found: ' + name); process.exit(1); }
|
|
373
|
-
const newReg = reg.filter(p => p.name !== name);
|
|
374
|
-
saveRegistry(newReg);
|
|
375
|
-
if (plugin.file) {
|
|
376
|
-
const pluginFile = path.resolve(pluginsDir, plugin.file);
|
|
377
|
-
// Prevent path traversal — only delete files inside pluginsDir
|
|
378
|
-
if (pluginFile.startsWith(path.resolve(pluginsDir) + path.sep) && fs.existsSync(pluginFile)) {
|
|
379
|
-
fs.unlinkSync(pluginFile);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
console.log(' Plugin "' + name + '" removed.');
|
|
383
|
-
break;
|
|
384
|
-
}
|
|
385
|
-
case 'enable': {
|
|
386
|
-
const name = process.argv[4];
|
|
387
|
-
if (!name) { console.error(' Usage: npx let-them-talk plugin enable <name>'); process.exit(1); }
|
|
388
|
-
const reg = getRegistry();
|
|
389
|
-
const plugin = reg.find(p => p.name === name);
|
|
390
|
-
if (!plugin) { console.error(' Plugin not found: ' + name); process.exit(1); }
|
|
391
|
-
plugin.enabled = true;
|
|
392
|
-
saveRegistry(reg);
|
|
393
|
-
console.log(' Plugin "' + name + '" enabled.');
|
|
394
|
-
break;
|
|
395
|
-
}
|
|
396
|
-
case 'disable': {
|
|
397
|
-
const name = process.argv[4];
|
|
398
|
-
if (!name) { console.error(' Usage: npx let-them-talk plugin disable <name>'); process.exit(1); }
|
|
399
|
-
const reg = getRegistry();
|
|
400
|
-
const plugin = reg.find(p => p.name === name);
|
|
401
|
-
if (!plugin) { console.error(' Plugin not found: ' + name); process.exit(1); }
|
|
402
|
-
plugin.enabled = false;
|
|
403
|
-
saveRegistry(reg);
|
|
404
|
-
console.log(' Plugin "' + name + '" disabled.');
|
|
405
|
-
break;
|
|
406
|
-
}
|
|
407
|
-
default:
|
|
408
|
-
console.error(' Unknown plugin command: ' + (subCmd || ''));
|
|
409
|
-
console.error(' Available: list, add, remove, enable, disable');
|
|
410
|
-
process.exit(1);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
291
|
function dashboard() {
|
|
415
292
|
if (process.argv.includes('--lan')) {
|
|
416
293
|
process.env.AGENT_BRIDGE_LAN = 'true';
|
|
@@ -532,7 +409,7 @@ switch (command) {
|
|
|
532
409
|
break;
|
|
533
410
|
case 'plugin':
|
|
534
411
|
case 'plugins':
|
|
535
|
-
|
|
412
|
+
console.log(' Plugins have been removed in v3.4.3. CLI terminals have their own extension systems.');
|
|
536
413
|
break;
|
|
537
414
|
case 'help':
|
|
538
415
|
case '--help':
|
package/dashboard.html
CHANGED
|
@@ -2572,65 +2572,7 @@
|
|
|
2572
2572
|
}
|
|
2573
2573
|
|
|
2574
2574
|
/* ===== v3.0: PLUGINS SECTION ===== */
|
|
2575
|
-
.
|
|
2576
|
-
background: var(--surface-2);
|
|
2577
|
-
border: 1px solid var(--border);
|
|
2578
|
-
border-radius: 6px;
|
|
2579
|
-
padding: 8px 10px;
|
|
2580
|
-
margin-bottom: 4px;
|
|
2581
|
-
display: flex;
|
|
2582
|
-
align-items: center;
|
|
2583
|
-
justify-content: space-between;
|
|
2584
|
-
}
|
|
2585
|
-
|
|
2586
|
-
.plugin-info {
|
|
2587
|
-
flex: 1;
|
|
2588
|
-
min-width: 0;
|
|
2589
|
-
}
|
|
2590
|
-
|
|
2591
|
-
.plugin-name {
|
|
2592
|
-
font-size: 12px;
|
|
2593
|
-
font-weight: 600;
|
|
2594
|
-
}
|
|
2595
|
-
|
|
2596
|
-
.plugin-desc {
|
|
2597
|
-
font-size: 10px;
|
|
2598
|
-
color: var(--text-muted);
|
|
2599
|
-
}
|
|
2600
|
-
|
|
2601
|
-
.plugin-toggle {
|
|
2602
|
-
background: var(--surface-3);
|
|
2603
|
-
border: 1px solid var(--border);
|
|
2604
|
-
border-radius: 10px;
|
|
2605
|
-
width: 36px;
|
|
2606
|
-
height: 20px;
|
|
2607
|
-
cursor: pointer;
|
|
2608
|
-
position: relative;
|
|
2609
|
-
transition: all 0.2s;
|
|
2610
|
-
flex-shrink: 0;
|
|
2611
|
-
}
|
|
2612
|
-
|
|
2613
|
-
.plugin-toggle.on {
|
|
2614
|
-
background: var(--green-dim);
|
|
2615
|
-
border-color: var(--green);
|
|
2616
|
-
}
|
|
2617
|
-
|
|
2618
|
-
.plugin-toggle::after {
|
|
2619
|
-
content: '';
|
|
2620
|
-
position: absolute;
|
|
2621
|
-
width: 14px;
|
|
2622
|
-
height: 14px;
|
|
2623
|
-
border-radius: 50%;
|
|
2624
|
-
background: var(--text-dim);
|
|
2625
|
-
top: 2px;
|
|
2626
|
-
left: 2px;
|
|
2627
|
-
transition: all 0.2s;
|
|
2628
|
-
}
|
|
2629
|
-
|
|
2630
|
-
.plugin-toggle.on::after {
|
|
2631
|
-
left: 18px;
|
|
2632
|
-
background: var(--green);
|
|
2633
|
-
}
|
|
2575
|
+
/* Plugins removed in v3.4.3 */
|
|
2634
2576
|
|
|
2635
2577
|
/* ===== v3.0: AVATAR PICKER ===== */
|
|
2636
2578
|
.avatar-option {
|
|
@@ -2758,12 +2700,6 @@
|
|
|
2758
2700
|
<div id="activity-heatmap"></div>
|
|
2759
2701
|
</div>
|
|
2760
2702
|
|
|
2761
|
-
<!-- Plugins Section -->
|
|
2762
|
-
<div class="sidebar-section">
|
|
2763
|
-
<div class="sidebar-title"><span>Plugins</span></div>
|
|
2764
|
-
<div id="plugins-list"></div>
|
|
2765
|
-
</div>
|
|
2766
|
-
|
|
2767
2703
|
<!-- Bookmarks Section -->
|
|
2768
2704
|
<div class="sidebar-section">
|
|
2769
2705
|
<div class="sidebar-title">
|
|
@@ -2842,7 +2778,7 @@
|
|
|
2842
2778
|
</div>
|
|
2843
2779
|
</div>
|
|
2844
2780
|
<div class="app-footer">
|
|
2845
|
-
<span>Let Them Talk v3.4.
|
|
2781
|
+
<span>Let Them Talk v3.4.3</span>
|
|
2846
2782
|
</div>
|
|
2847
2783
|
<div class="profile-popup" id="profile-popup" onclick="event.stopPropagation()">
|
|
2848
2784
|
<div class="profile-popup-header">
|
|
@@ -2897,6 +2833,26 @@ var POLL_INTERVAL = 2000;
|
|
|
2897
2833
|
var SLEEP_THRESHOLD = 60; // seconds
|
|
2898
2834
|
var lastMessageCount = 0;
|
|
2899
2835
|
var autoScroll = true;
|
|
2836
|
+
// CSRF protection — all mutating requests must include this header
|
|
2837
|
+
// Read LAN token from URL on page load (phone access via QR code)
|
|
2838
|
+
var _lttToken = (function() {
|
|
2839
|
+
try { var p = new URLSearchParams(window.location.search); var t = p.get('token'); if (t) sessionStorage.setItem('ltt-token', t); return sessionStorage.getItem('ltt-token') || ''; } catch(e) { return ''; }
|
|
2840
|
+
})();
|
|
2841
|
+
|
|
2842
|
+
function lttFetch(url, opts) {
|
|
2843
|
+
opts = opts || {};
|
|
2844
|
+
// Append LAN token to URL if present (needed for phone/LAN access)
|
|
2845
|
+
if (_lttToken) {
|
|
2846
|
+
var sep = url.indexOf('?') >= 0 ? '&' : '?';
|
|
2847
|
+
url = url + sep + 'token=' + encodeURIComponent(_lttToken);
|
|
2848
|
+
}
|
|
2849
|
+
if (opts.method && opts.method !== 'GET') {
|
|
2850
|
+
if (!opts.headers) opts.headers = {};
|
|
2851
|
+
opts.headers['X-LTT-Request'] = '1';
|
|
2852
|
+
}
|
|
2853
|
+
return fetch(url, opts);
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2900
2856
|
var activeThread = null;
|
|
2901
2857
|
var activeProject = ''; // empty = default/local
|
|
2902
2858
|
var cachedHistory = [];
|
|
@@ -3227,7 +3183,7 @@ function renderAgents(agents) {
|
|
|
3227
3183
|
|
|
3228
3184
|
function sendNudge(agentName) {
|
|
3229
3185
|
var body = JSON.stringify({ to: agentName, content: 'Hey ' + agentName + ', the user is waiting for you. Please check for new messages and continue your work.' });
|
|
3230
|
-
|
|
3186
|
+
lttFetch('/api/inject' + projectParam(), {
|
|
3231
3187
|
method: 'POST',
|
|
3232
3188
|
headers: { 'Content-Type': 'application/json' },
|
|
3233
3189
|
body: body
|
|
@@ -3285,7 +3241,7 @@ function doInject() {
|
|
|
3285
3241
|
if (!target || !content) return;
|
|
3286
3242
|
|
|
3287
3243
|
var body = JSON.stringify({ to: target, content: content });
|
|
3288
|
-
|
|
3244
|
+
lttFetch('/api/inject' + projectParam(), {
|
|
3289
3245
|
method: 'POST',
|
|
3290
3246
|
headers: { 'Content-Type': 'application/json' },
|
|
3291
3247
|
body: body
|
|
@@ -3612,7 +3568,7 @@ var cachedReadReceipts = {};
|
|
|
3612
3568
|
|
|
3613
3569
|
function fetchReadReceipts() {
|
|
3614
3570
|
var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
|
|
3615
|
-
|
|
3571
|
+
lttFetch('/api/read-receipts' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
3616
3572
|
cachedReadReceipts = data || {};
|
|
3617
3573
|
}).catch(function() {});
|
|
3618
3574
|
}
|
|
@@ -3752,7 +3708,7 @@ function deleteMessage(msgId) {
|
|
|
3752
3708
|
var preview = msg.content.substring(0, 60) + (msg.content.length > 60 ? '...' : '');
|
|
3753
3709
|
if (!confirm('Delete this message from ' + msg.from + '?\n\n"' + preview + '"')) return;
|
|
3754
3710
|
var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
|
|
3755
|
-
|
|
3711
|
+
lttFetch('/api/message' + pq, {
|
|
3756
3712
|
method: 'DELETE',
|
|
3757
3713
|
headers: { 'Content-Type': 'application/json' },
|
|
3758
3714
|
body: JSON.stringify({ id: msgId })
|
|
@@ -3802,7 +3758,7 @@ function saveEditMessage() {
|
|
|
3802
3758
|
var content = document.getElementById('edit-msg-content').value.trim();
|
|
3803
3759
|
if (!content) return;
|
|
3804
3760
|
var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
|
|
3805
|
-
|
|
3761
|
+
lttFetch('/api/message' + pq, {
|
|
3806
3762
|
method: 'PUT',
|
|
3807
3763
|
headers: { 'Content-Type': 'application/json' },
|
|
3808
3764
|
body: JSON.stringify({ id: msgId, content: content })
|
|
@@ -3994,7 +3950,7 @@ function renderAgentStats() {
|
|
|
3994
3950
|
|
|
3995
3951
|
function fetchActivity() {
|
|
3996
3952
|
var pq = projectParam();
|
|
3997
|
-
|
|
3953
|
+
lttFetch('/api/timeline' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
3998
3954
|
renderActivityHeatmap(data);
|
|
3999
3955
|
}).catch(function() {});
|
|
4000
3956
|
}
|
|
@@ -4174,7 +4130,7 @@ var cachedTasks = [];
|
|
|
4174
4130
|
|
|
4175
4131
|
function fetchTasks() {
|
|
4176
4132
|
var pq = projectParam();
|
|
4177
|
-
|
|
4133
|
+
lttFetch('/api/tasks' + pq).then(function(r) { return r.json(); }).then(function(tasks) {
|
|
4178
4134
|
cachedTasks = Array.isArray(tasks) ? tasks : [];
|
|
4179
4135
|
renderTasks();
|
|
4180
4136
|
}).catch(function() {
|
|
@@ -4265,7 +4221,7 @@ function buildTaskCard(t) {
|
|
|
4265
4221
|
}
|
|
4266
4222
|
|
|
4267
4223
|
function updateTaskStatus(taskId, newStatus) {
|
|
4268
|
-
|
|
4224
|
+
lttFetch('/api/tasks' + projectParam(), {
|
|
4269
4225
|
method: 'POST',
|
|
4270
4226
|
headers: { 'Content-Type': 'application/json' },
|
|
4271
4227
|
body: JSON.stringify({ task_id: taskId, status: newStatus })
|
|
@@ -4322,7 +4278,7 @@ var AGENT_COLORS = ['#58a6ff', '#f78166', '#7ee787', '#d2a8ff', '#ffa657', '#ff7
|
|
|
4322
4278
|
|
|
4323
4279
|
function fetchStats() {
|
|
4324
4280
|
var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
|
|
4325
|
-
|
|
4281
|
+
lttFetch('/api/stats' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
4326
4282
|
renderStats(data);
|
|
4327
4283
|
}).catch(function(e) { console.error('Stats fetch failed:', e); });
|
|
4328
4284
|
}
|
|
@@ -4571,7 +4527,7 @@ function saveProfile() {
|
|
|
4571
4527
|
};
|
|
4572
4528
|
if (avatar) body.avatar = avatar;
|
|
4573
4529
|
|
|
4574
|
-
|
|
4530
|
+
lttFetch('/api/profiles' + projectParam(), {
|
|
4575
4531
|
method: 'POST',
|
|
4576
4532
|
headers: { 'Content-Type': 'application/json' },
|
|
4577
4533
|
body: JSON.stringify(body)
|
|
@@ -4587,7 +4543,7 @@ function saveProfile() {
|
|
|
4587
4543
|
|
|
4588
4544
|
function fetchWorkspaces() {
|
|
4589
4545
|
var pq = projectParam();
|
|
4590
|
-
|
|
4546
|
+
lttFetch('/api/workspaces' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
4591
4547
|
renderWorkspaces(data);
|
|
4592
4548
|
}).catch(function() {});
|
|
4593
4549
|
}
|
|
@@ -4634,7 +4590,7 @@ function renderWorkspaces(data) {
|
|
|
4634
4590
|
|
|
4635
4591
|
function fetchWorkflows() {
|
|
4636
4592
|
var pq = projectParam();
|
|
4637
|
-
|
|
4593
|
+
lttFetch('/api/workflows' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
4638
4594
|
renderWorkflows(Array.isArray(data) ? data : []);
|
|
4639
4595
|
}).catch(function() {});
|
|
4640
4596
|
}
|
|
@@ -4685,7 +4641,7 @@ function renderWorkflows(workflows) {
|
|
|
4685
4641
|
}
|
|
4686
4642
|
|
|
4687
4643
|
function dashAdvanceWorkflow(wfId) {
|
|
4688
|
-
|
|
4644
|
+
lttFetch('/api/workflows' + projectParam(), {
|
|
4689
4645
|
method: 'POST',
|
|
4690
4646
|
headers: { 'Content-Type': 'application/json' },
|
|
4691
4647
|
body: JSON.stringify({ action: 'advance', workflow_id: wfId })
|
|
@@ -4698,7 +4654,7 @@ var activeBranch = '';
|
|
|
4698
4654
|
|
|
4699
4655
|
function fetchBranches() {
|
|
4700
4656
|
var pq = projectParam();
|
|
4701
|
-
|
|
4657
|
+
lttFetch('/api/branches' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
4702
4658
|
renderBranchTabs(data);
|
|
4703
4659
|
}).catch(function() {});
|
|
4704
4660
|
}
|
|
@@ -4730,44 +4686,6 @@ function switchBranch(name) {
|
|
|
4730
4686
|
poll();
|
|
4731
4687
|
}
|
|
4732
4688
|
|
|
4733
|
-
// ==================== v3.0: PLUGINS ====================
|
|
4734
|
-
|
|
4735
|
-
function fetchPlugins() {
|
|
4736
|
-
var pq = projectParam();
|
|
4737
|
-
fetch('/api/plugins' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
4738
|
-
renderPlugins(Array.isArray(data) ? data : []);
|
|
4739
|
-
}).catch(function() {});
|
|
4740
|
-
}
|
|
4741
|
-
|
|
4742
|
-
function renderPlugins(plugins) {
|
|
4743
|
-
var el = document.getElementById('plugins-list');
|
|
4744
|
-
if (!plugins.length) {
|
|
4745
|
-
el.innerHTML = '<div style="color:var(--text-muted);font-size:12px;padding:4px;">No plugins installed</div>';
|
|
4746
|
-
return;
|
|
4747
|
-
}
|
|
4748
|
-
var html = '';
|
|
4749
|
-
for (var i = 0; i < plugins.length; i++) {
|
|
4750
|
-
var p = plugins[i];
|
|
4751
|
-
var onClass = p.enabled !== false ? ' on' : '';
|
|
4752
|
-
html += '<div class="plugin-card">' +
|
|
4753
|
-
'<div class="plugin-info">' +
|
|
4754
|
-
'<div class="plugin-name">' + escapeHtml(p.name) + '</div>' +
|
|
4755
|
-
'<div class="plugin-desc">' + escapeHtml(p.description || '') + '</div>' +
|
|
4756
|
-
'</div>' +
|
|
4757
|
-
'<div class="plugin-toggle' + onClass + '" onclick="togglePlugin(\'' + escapeHtml(p.name) + '\')"></div>' +
|
|
4758
|
-
'</div>';
|
|
4759
|
-
}
|
|
4760
|
-
el.innerHTML = html;
|
|
4761
|
-
}
|
|
4762
|
-
|
|
4763
|
-
function togglePlugin(name) {
|
|
4764
|
-
fetch('/api/plugins' + projectParam(), {
|
|
4765
|
-
method: 'POST',
|
|
4766
|
-
headers: { 'Content-Type': 'application/json' },
|
|
4767
|
-
body: JSON.stringify({ action: 'toggle', name: name })
|
|
4768
|
-
}).then(function() { fetchPlugins(); }).catch(function() {});
|
|
4769
|
-
}
|
|
4770
|
-
|
|
4771
4689
|
// ==================== POLLING ====================
|
|
4772
4690
|
|
|
4773
4691
|
function poll() {
|
|
@@ -4776,9 +4694,9 @@ function poll() {
|
|
|
4776
4694
|
var bp = activeBranch && activeBranch !== 'main' ? '&branch=' + encodeURIComponent(activeBranch) : '';
|
|
4777
4695
|
var pollStart = Date.now();
|
|
4778
4696
|
Promise.all([
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4697
|
+
lttFetch('/api/history?limit=500' + pp + bp).then(function(r) { return r.json(); }),
|
|
4698
|
+
lttFetch('/api/agents' + pq).then(function(r) { return r.json(); }),
|
|
4699
|
+
lttFetch('/api/status' + pq).then(function(r) { return r.json(); }),
|
|
4782
4700
|
]).then(function(results) {
|
|
4783
4701
|
console.log('[LTT] poll ok — history:' + results[0].length + ' agents:' + Object.keys(results[1]).length + ' project:' + (activeProject || 'default'));
|
|
4784
4702
|
updateConnectionInfo(Date.now() - pollStart);
|
|
@@ -4819,7 +4737,6 @@ function poll() {
|
|
|
4819
4737
|
renderBookmarksSidebar();
|
|
4820
4738
|
fetchActivity();
|
|
4821
4739
|
fetchBranches();
|
|
4822
|
-
fetchPlugins();
|
|
4823
4740
|
updateTypingIndicator(cachedAgents);
|
|
4824
4741
|
if (activeView === 'tasks') fetchTasks();
|
|
4825
4742
|
if (activeView === 'workspaces') fetchWorkspaces();
|
|
@@ -4834,7 +4751,7 @@ function poll() {
|
|
|
4834
4751
|
|
|
4835
4752
|
function doReset() {
|
|
4836
4753
|
if (!confirm('Clear all messages, agents, and history?')) return;
|
|
4837
|
-
|
|
4754
|
+
lttFetch('/api/reset' + projectParam(), { method: 'POST' }).then(function() {
|
|
4838
4755
|
lastMessageCount = 0;
|
|
4839
4756
|
activeThread = null;
|
|
4840
4757
|
cachedHistory = [];
|
|
@@ -4846,7 +4763,7 @@ function doReset() {
|
|
|
4846
4763
|
// ==================== PROJECT MANAGEMENT ====================
|
|
4847
4764
|
|
|
4848
4765
|
function loadProjects() {
|
|
4849
|
-
return
|
|
4766
|
+
return lttFetch('/api/projects').then(function(r) { return r.json(); }).then(function(projects) {
|
|
4850
4767
|
console.log('[LTT] loadProjects:', projects.length, 'projects, activeProject:', activeProject);
|
|
4851
4768
|
var sel = document.getElementById('project-select');
|
|
4852
4769
|
// Keep the first option (Default)
|
|
@@ -4944,7 +4861,7 @@ function addProject() {
|
|
|
4944
4861
|
var projectPath = input.value.trim();
|
|
4945
4862
|
if (!projectPath) return;
|
|
4946
4863
|
|
|
4947
|
-
|
|
4864
|
+
lttFetch('/api/projects', {
|
|
4948
4865
|
method: 'POST',
|
|
4949
4866
|
headers: { 'Content-Type': 'application/json' },
|
|
4950
4867
|
body: JSON.stringify({ path: projectPath })
|
|
@@ -4970,7 +4887,7 @@ function discoverProjects() {
|
|
|
4970
4887
|
resultsEl.style.display = 'block';
|
|
4971
4888
|
resultsEl.innerHTML = '<div style="font-size:11px;color:var(--text-muted);padding:4px;">Scanning...</div>';
|
|
4972
4889
|
|
|
4973
|
-
|
|
4890
|
+
lttFetch('/api/discover', { method: 'POST' }).then(function(r) { return r.json(); }).then(function(found) {
|
|
4974
4891
|
if (!found.length) {
|
|
4975
4892
|
resultsEl.innerHTML = '<div style="font-size:11px;color:var(--text-muted);padding:4px;">No new projects found (all discovered projects already added)</div>';
|
|
4976
4893
|
setTimeout(function() { resultsEl.style.display = 'none'; }, 3000);
|
|
@@ -4996,7 +4913,7 @@ function discoverProjects() {
|
|
|
4996
4913
|
}
|
|
4997
4914
|
|
|
4998
4915
|
function addDiscovered(projectPath, name) {
|
|
4999
|
-
|
|
4916
|
+
lttFetch('/api/projects', {
|
|
5000
4917
|
method: 'POST',
|
|
5001
4918
|
headers: { 'Content-Type': 'application/json' },
|
|
5002
4919
|
body: JSON.stringify({ path: projectPath, name: name })
|
|
@@ -5012,7 +4929,7 @@ function removeProject() {
|
|
|
5012
4929
|
if (!activeProject) return;
|
|
5013
4930
|
if (!confirm('Remove this project from the dashboard?')) return;
|
|
5014
4931
|
|
|
5015
|
-
|
|
4932
|
+
lttFetch('/api/projects', {
|
|
5016
4933
|
method: 'DELETE',
|
|
5017
4934
|
headers: { 'Content-Type': 'application/json' },
|
|
5018
4935
|
body: JSON.stringify({ path: activeProject })
|
|
@@ -5275,7 +5192,7 @@ updateNotifBtn();
|
|
|
5275
5192
|
var lanState = { lan_mode: false, lan_ip: null, port: 3000 };
|
|
5276
5193
|
|
|
5277
5194
|
function fetchLanState() {
|
|
5278
|
-
return
|
|
5195
|
+
return lttFetch('/api/server-info').then(function(r) { return r.json(); }).then(function(info) {
|
|
5279
5196
|
lanState = info;
|
|
5280
5197
|
updateLanUI();
|
|
5281
5198
|
return info;
|
|
@@ -5325,7 +5242,7 @@ function toggleLanMode() {
|
|
|
5325
5242
|
var content = document.getElementById('phone-modal-content');
|
|
5326
5243
|
content.innerHTML = '<div class="phone-off-state"><p>Switching...</p></div>';
|
|
5327
5244
|
|
|
5328
|
-
|
|
5245
|
+
lttFetch('/api/toggle-lan', { method: 'POST' })
|
|
5329
5246
|
.then(function(r) { return r.json(); })
|
|
5330
5247
|
.then(function(info) {
|
|
5331
5248
|
lanState = info;
|
|
@@ -5353,10 +5270,20 @@ function renderPhoneModalContent() {
|
|
|
5353
5270
|
}
|
|
5354
5271
|
|
|
5355
5272
|
var url = 'http://' + lanState.lan_ip + ':' + lanState.port;
|
|
5356
|
-
// Include active project so the phone shows the same view
|
|
5357
|
-
|
|
5273
|
+
// Include auth token and active project so the phone shows the same view
|
|
5274
|
+
var params = [];
|
|
5275
|
+
if (lanState.lan_token) params.push('token=' + encodeURIComponent(lanState.lan_token));
|
|
5276
|
+
if (activeProject) params.push('project=' + encodeURIComponent(activeProject));
|
|
5277
|
+
if (params.length) url += '?' + params.join('&');
|
|
5358
5278
|
var qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&color=58a6ff&bgcolor=0d1117&data=' + encodeURIComponent(url);
|
|
5359
5279
|
|
|
5280
|
+
var tokenHtml = lanState.lan_token ?
|
|
5281
|
+
'<div style="margin-top:12px;background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:10px">' +
|
|
5282
|
+
'<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px">Auth Token</div>' +
|
|
5283
|
+
'<div style="font-family:monospace;font-size:13px;color:var(--accent);word-break:break-all">' + escapeHtml(lanState.lan_token) + '</div>' +
|
|
5284
|
+
'<div style="font-size:10px;color:var(--text-muted);margin-top:4px">Included in the QR code URL. Only people with this token can access your dashboard.</div>' +
|
|
5285
|
+
'</div>' : '';
|
|
5286
|
+
|
|
5360
5287
|
el.innerHTML =
|
|
5361
5288
|
'<div class="phone-url-box">' +
|
|
5362
5289
|
'<div class="phone-url-text">' + escapeHtml(url) + '</div>' +
|
|
@@ -5365,12 +5292,16 @@ function renderPhoneModalContent() {
|
|
|
5365
5292
|
'<div class="phone-qr">' +
|
|
5366
5293
|
'<img src="' + qrUrl + '" alt="QR Code" onerror="this.parentElement.style.display=\'none\'">' +
|
|
5367
5294
|
'</div>' +
|
|
5368
|
-
'<div class="phone-qr-hint">Scan with your phone camera on the same WiFi</div>'
|
|
5295
|
+
'<div class="phone-qr-hint">Scan with your phone camera on the same WiFi</div>' +
|
|
5296
|
+
tokenHtml;
|
|
5369
5297
|
}
|
|
5370
5298
|
|
|
5371
5299
|
function copyPhoneUrl() {
|
|
5372
5300
|
var url = 'http://' + lanState.lan_ip + ':' + lanState.port;
|
|
5373
|
-
|
|
5301
|
+
var params = [];
|
|
5302
|
+
if (lanState.lan_token) params.push('token=' + encodeURIComponent(lanState.lan_token));
|
|
5303
|
+
if (activeProject) params.push('project=' + encodeURIComponent(activeProject));
|
|
5304
|
+
if (params.length) url += '?' + params.join('&');
|
|
5374
5305
|
navigator.clipboard.writeText(url).then(function() {
|
|
5375
5306
|
var btn = document.querySelector('.phone-url-copy');
|
|
5376
5307
|
btn.textContent = 'Copied!';
|
|
@@ -5389,7 +5320,7 @@ var selectedCli = 'claude';
|
|
|
5389
5320
|
function renderLaunchPanel() {
|
|
5390
5321
|
var el = document.getElementById('launch-area');
|
|
5391
5322
|
// Fetch templates
|
|
5392
|
-
|
|
5323
|
+
lttFetch('/api/templates').then(function(r) { return r.json(); }).then(function(templates) {
|
|
5393
5324
|
launchTemplates = templates;
|
|
5394
5325
|
var templateOpts = '<option value="">-- No template --</option>';
|
|
5395
5326
|
for (var i = 0; i < templates.length; i++) {
|
|
@@ -5436,8 +5367,8 @@ function renderLaunchPanel() {
|
|
|
5436
5367
|
}
|
|
5437
5368
|
|
|
5438
5369
|
// ==================== v3.4: CONVERSATION TEMPLATES ====================
|
|
5439
|
-
function renderConversationTemplates(p){
|
|
5440
|
-
function showConvTemplate(tid){var pq=activeProject?'?project='+encodeURIComponent(activeProject):'';
|
|
5370
|
+
function renderConversationTemplates(p){lttFetch('/api/conversation-templates').then(function(r){return r.json()}).then(function(t){if(!t.length)return;var s=document.createElement('div');s.className='launch-panel';s.style.marginTop='20px';var h='<h3 style="margin-bottom:4px">Conversation Templates</h3><p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">Pre-built multi-agent workflows. Click to see agent prompts.</p><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">';for(var i=0;i<t.length;i++){var c=t[i];var n=c.agents.map(function(a){return a.name}).join(', ');h+='<div style="background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:14px;cursor:pointer;transition:all 0.15s" onclick="showConvTemplate(\''+escapeHtml(c.id)+'\')" onmouseover="this.style.borderColor=\'var(--accent)\'" onmouseout="this.style.borderColor=\'var(--border)\'"><div style="font-weight:600;font-size:13px;margin-bottom:4px">'+escapeHtml(c.name)+'</div><div style="font-size:11px;color:var(--text-dim);margin-bottom:8px">'+escapeHtml(c.description)+'</div><div style="font-size:10px;color:var(--text-muted)">Agents: '+escapeHtml(n)+'</div>'+(c.workflow?'<div style="font-size:10px;color:var(--purple);margin-top:4px">Workflow: '+escapeHtml(c.workflow.steps.join(' \u2192 '))+'</div>':'')+'</div>'}h+='</div><div id="conv-template-detail" style="margin-top:16px"></div>';s.innerHTML=h;p.appendChild(s)}).catch(function(){})}
|
|
5371
|
+
function showConvTemplate(tid){var pq=activeProject?'?project='+encodeURIComponent(activeProject):'';lttFetch('/api/conversation-templates/launch'+pq,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({template_id:tid})}).then(function(r){return r.json()}).then(function(d){if(d.error){alert(d.error);return}var el=document.getElementById('conv-template-detail');var h='<div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:16px"><div style="font-weight:700;font-size:15px;margin-bottom:4px">'+escapeHtml(d.template.name)+'</div><div style="font-size:12px;color:var(--text-dim);margin-bottom:12px">'+escapeHtml(d.template.description)+'</div>';if(d.template.workflow){h+='<div style="display:flex;gap:6px;align-items:center;margin-bottom:14px;flex-wrap:wrap">';var st=d.template.workflow.steps;for(var s=0;s<st.length;s++){if(s>0)h+='<span style="color:var(--text-muted);font-size:12px">\u2192</span>';h+='<span style="background:var(--purple-dim);color:var(--purple);padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600">'+escapeHtml(st[s])+'</span>'}h+='</div>'}h+='<div style="font-weight:600;font-size:12px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px">Agent Prompts (copy each into a separate terminal)</div>';for(var i=0;i<d.instructions.length;i++){var inst=d.instructions[i];h+='<div style="margin-bottom:10px"><div style="display:flex;align-items:center;gap:8px;margin-bottom:4px"><span style="font-weight:600;font-size:13px">'+escapeHtml(inst.agent_name)+'</span><span class="role-badge">'+escapeHtml(inst.role)+'</span></div><div class="copy-block" onclick="copyText(this)" data-text="'+inst.prompt.replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>')+'"><span class="copy-hint">click to copy</span><span style="font-size:11px;color:var(--text-dim)">'+escapeHtml(inst.prompt.substring(0,200))+(inst.prompt.length>200?'...':'')+'</span></div></div>'}h+='</div>';el.innerHTML=h;el.scrollIntoView({behavior:'smooth',block:'nearest'})})}
|
|
5441
5372
|
|
|
5442
5373
|
function selectCli(cli) {
|
|
5443
5374
|
selectedCli = cli;
|
|
@@ -5471,7 +5402,7 @@ function doLaunch() {
|
|
|
5471
5402
|
var prompt = document.getElementById('launch-prompt').value.trim();
|
|
5472
5403
|
var resultEl = document.getElementById('launch-result');
|
|
5473
5404
|
|
|
5474
|
-
|
|
5405
|
+
lttFetch('/api/launch', {
|
|
5475
5406
|
method: 'POST',
|
|
5476
5407
|
headers: { 'Content-Type': 'application/json' },
|
|
5477
5408
|
body: JSON.stringify({ cli: selectedCli, project_dir: projectDir || undefined, agent_name: agentName, prompt: prompt || undefined })
|
|
@@ -5539,7 +5470,8 @@ function setConnStatus(status) {
|
|
|
5539
5470
|
|
|
5540
5471
|
function initSSE() {
|
|
5541
5472
|
try {
|
|
5542
|
-
var
|
|
5473
|
+
var sseUrl = '/api/events' + (_lttToken ? '?token=' + encodeURIComponent(_lttToken) : '');
|
|
5474
|
+
var eventSource = new EventSource(sseUrl);
|
|
5543
5475
|
eventSource.onmessage = function(e) {
|
|
5544
5476
|
if (e.data === 'update' || e.data === 'connected') {
|
|
5545
5477
|
poll();
|
package/dashboard.js
CHANGED
|
@@ -18,6 +18,26 @@ const PORT = parseInt(process.env.AGENT_BRIDGE_PORT || '3000', 10);
|
|
|
18
18
|
const LAN_STATE_FILE = path.join(__dirname, '.lan-mode');
|
|
19
19
|
let LAN_MODE = process.env.AGENT_BRIDGE_LAN === 'true' || (fs.existsSync(LAN_STATE_FILE) && fs.readFileSync(LAN_STATE_FILE, 'utf8').trim() === 'true');
|
|
20
20
|
|
|
21
|
+
const LAN_TOKEN_FILE = path.join(__dirname, '.lan-token');
|
|
22
|
+
let LAN_TOKEN = null;
|
|
23
|
+
|
|
24
|
+
function generateLanToken() {
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
LAN_TOKEN = crypto.randomBytes(16).toString('hex');
|
|
27
|
+
try { fs.writeFileSync(LAN_TOKEN_FILE, LAN_TOKEN); } catch {}
|
|
28
|
+
return LAN_TOKEN;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadLanToken() {
|
|
32
|
+
if (fs.existsSync(LAN_TOKEN_FILE)) {
|
|
33
|
+
try { LAN_TOKEN = fs.readFileSync(LAN_TOKEN_FILE, 'utf8').trim(); } catch {}
|
|
34
|
+
}
|
|
35
|
+
if (!LAN_TOKEN) generateLanToken();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Load or generate token on startup
|
|
39
|
+
loadLanToken();
|
|
40
|
+
|
|
21
41
|
function persistLanMode() {
|
|
22
42
|
try { fs.writeFileSync(LAN_STATE_FILE, LAN_MODE ? 'true' : 'false'); } catch {}
|
|
23
43
|
}
|
|
@@ -308,7 +328,7 @@ function apiStats(query) {
|
|
|
308
328
|
function apiReset(query) {
|
|
309
329
|
const projectPath = query.get('project') || null;
|
|
310
330
|
const dataDir = resolveDataDir(projectPath);
|
|
311
|
-
const fixedFiles = ['messages.jsonl', 'history.jsonl', 'agents.json', 'acks.json', 'tasks.json', 'profiles.json', 'workflows.json', 'branches.json', '
|
|
331
|
+
const fixedFiles = ['messages.jsonl', 'history.jsonl', 'agents.json', 'acks.json', 'tasks.json', 'profiles.json', 'workflows.json', 'branches.json', 'read_receipts.json', 'permissions.json'];
|
|
312
332
|
for (const f of fixedFiles) {
|
|
313
333
|
const p = path.join(dataDir, f);
|
|
314
334
|
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
@@ -1009,13 +1029,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
1009
1029
|
|
|
1010
1030
|
const allowedOrigin = `http://localhost:${PORT}`;
|
|
1011
1031
|
const reqOrigin = req.headers.origin;
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1032
|
+
const lanIP = getLanIP();
|
|
1033
|
+
const lanOrigin = lanIP ? `http://${lanIP}:${PORT}` : null;
|
|
1034
|
+
const trustedOrigins = [allowedOrigin, `http://127.0.0.1:${PORT}`];
|
|
1035
|
+
if (LAN_MODE && lanOrigin) trustedOrigins.push(lanOrigin);
|
|
1036
|
+
if (reqOrigin && trustedOrigins.includes(reqOrigin)) {
|
|
1015
1037
|
res.setHeader('Access-Control-Allow-Origin', reqOrigin);
|
|
1016
1038
|
}
|
|
1017
1039
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
1018
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
1040
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-LTT-Request, X-LTT-Token');
|
|
1019
1041
|
|
|
1020
1042
|
if (req.method === 'OPTIONS') {
|
|
1021
1043
|
res.writeHead(204);
|
|
@@ -1023,7 +1045,23 @@ const server = http.createServer(async (req, res) => {
|
|
|
1023
1045
|
return;
|
|
1024
1046
|
}
|
|
1025
1047
|
|
|
1026
|
-
//
|
|
1048
|
+
// LAN auth token — required for non-localhost requests when LAN mode is active
|
|
1049
|
+
if (LAN_MODE) {
|
|
1050
|
+
const host = (req.headers.host || '').replace(/:\d+$/, '');
|
|
1051
|
+
const isLocalhost = host === 'localhost' || host === '127.0.0.1';
|
|
1052
|
+
if (!isLocalhost) {
|
|
1053
|
+
const tokenFromQuery = url.searchParams.get('token');
|
|
1054
|
+
const tokenFromHeader = req.headers['x-ltt-token'];
|
|
1055
|
+
const providedToken = tokenFromHeader || tokenFromQuery;
|
|
1056
|
+
if (!providedToken || providedToken !== LAN_TOKEN) {
|
|
1057
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1058
|
+
res.end(JSON.stringify({ error: 'Unauthorized: invalid or missing LAN token' }));
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// CSRF + DNS rebinding protection: validate Host, Origin, and custom header on mutating requests
|
|
1027
1065
|
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
|
|
1028
1066
|
// Check Host header to block DNS rebinding attacks
|
|
1029
1067
|
const host = (req.headers.host || '').replace(/:\d+$/, '');
|
|
@@ -1034,13 +1072,26 @@ const server = http.createServer(async (req, res) => {
|
|
|
1034
1072
|
res.end(JSON.stringify({ error: 'Forbidden: invalid host' }));
|
|
1035
1073
|
return;
|
|
1036
1074
|
}
|
|
1075
|
+
// Require custom header — browsers block cross-origin custom headers without preflight,
|
|
1076
|
+
// which our CORS policy won't approve for foreign origins. This closes the no-Origin gap.
|
|
1077
|
+
if (!req.headers['x-ltt-request']) {
|
|
1078
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1079
|
+
res.end(JSON.stringify({ error: 'Forbidden: missing X-LTT-Request header' }));
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1037
1082
|
// Check Origin header to block cross-site requests
|
|
1083
|
+
// Empty origin is NOT trusted — requires at least the custom header (checked above)
|
|
1038
1084
|
const origin = req.headers.origin || '';
|
|
1039
1085
|
const referer = req.headers.referer || '';
|
|
1040
1086
|
const source = origin || referer;
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1087
|
+
if (!source) {
|
|
1088
|
+
// No origin/referer — non-browser client (curl, scripts, etc.)
|
|
1089
|
+
// Custom header check above is the only protection layer here — allow through
|
|
1090
|
+
// since local CLI tools (like our own `msg` command) need to work
|
|
1091
|
+
}
|
|
1092
|
+
const isLocal = source && (source.includes('localhost:' + PORT) || source.includes('127.0.0.1:' + PORT));
|
|
1093
|
+
const isLan = LAN_MODE && getLanIP() && source && source.includes(getLanIP() + ':' + PORT);
|
|
1094
|
+
if (source && !isLocal && !isLan) {
|
|
1044
1095
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1045
1096
|
res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
|
|
1046
1097
|
return;
|
|
@@ -1074,6 +1125,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1074
1125
|
const html = fs.readFileSync(HTML_FILE, 'utf8');
|
|
1075
1126
|
res.writeHead(200, {
|
|
1076
1127
|
'Content-Type': 'text/html; charset=utf-8',
|
|
1128
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'",
|
|
1077
1129
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
1078
1130
|
'Pragma': 'no-cache',
|
|
1079
1131
|
'Expires': '0'
|
|
@@ -1248,27 +1300,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
1248
1300
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1249
1301
|
res.end(JSON.stringify(branches));
|
|
1250
1302
|
}
|
|
1251
|
-
else if (url.pathname === '/api/plugins' && req.method === 'GET') {
|
|
1252
|
-
const projectPath = url.searchParams.get('project') || null;
|
|
1253
|
-
const pluginsFile = filePath('plugins.json', projectPath);
|
|
1254
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1255
|
-
res.end(JSON.stringify(fs.existsSync(pluginsFile) ? JSON.parse(fs.readFileSync(pluginsFile, 'utf8')) : []));
|
|
1256
|
-
}
|
|
1257
|
-
else if (url.pathname === '/api/plugins' && req.method === 'POST') {
|
|
1258
|
-
const body = await parseBody(req);
|
|
1259
|
-
const projectPath = url.searchParams.get('project') || null;
|
|
1260
|
-
const pluginsFile = filePath('plugins.json', projectPath);
|
|
1261
|
-
let plugins = [];
|
|
1262
|
-
if (fs.existsSync(pluginsFile)) try { plugins = JSON.parse(fs.readFileSync(pluginsFile, 'utf8')); } catch {}
|
|
1263
|
-
if (body.action === 'toggle' && body.name) {
|
|
1264
|
-
const p = plugins.find(x => x.name === body.name);
|
|
1265
|
-
if (!p) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Plugin not found' })); return; }
|
|
1266
|
-
p.enabled = !p.enabled;
|
|
1267
|
-
fs.writeFileSync(pluginsFile, JSON.stringify(plugins, null, 2));
|
|
1268
|
-
}
|
|
1269
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1270
|
-
res.end(JSON.stringify({ success: true }));
|
|
1271
|
-
}
|
|
1272
1303
|
else if (url.pathname === '/api/projects' && req.method === 'DELETE') {
|
|
1273
1304
|
const body = await parseBody(req);
|
|
1274
1305
|
const result = apiRemoveProject(body);
|
|
@@ -1321,7 +1352,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1321
1352
|
// Server info (LAN mode detection for frontend)
|
|
1322
1353
|
else if (url.pathname === '/api/server-info' && req.method === 'GET') {
|
|
1323
1354
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1324
|
-
res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT }));
|
|
1355
|
+
res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT, lan_token: LAN_MODE ? LAN_TOKEN : null }));
|
|
1325
1356
|
}
|
|
1326
1357
|
// Toggle LAN mode (re-bind server live)
|
|
1327
1358
|
else if (url.pathname === '/api/toggle-lan' && req.method === 'POST') {
|
|
@@ -1329,9 +1360,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1329
1360
|
const lanIP = getLanIP();
|
|
1330
1361
|
LAN_MODE = newMode;
|
|
1331
1362
|
persistLanMode();
|
|
1363
|
+
// Regenerate token when enabling LAN mode
|
|
1364
|
+
if (newMode) generateLanToken();
|
|
1332
1365
|
// Send response first
|
|
1333
1366
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1334
|
-
res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT }));
|
|
1367
|
+
res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT, lan_token: newMode ? LAN_TOKEN : null }));
|
|
1335
1368
|
// Re-bind by stopping the listener and immediately re-listening
|
|
1336
1369
|
// Use setImmediate to let the response flush first
|
|
1337
1370
|
setImmediate(() => {
|
|
@@ -1447,7 +1480,7 @@ server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
|
|
|
1447
1480
|
const dataDir = resolveDataDir();
|
|
1448
1481
|
const lanIP = getLanIP();
|
|
1449
1482
|
console.log('');
|
|
1450
|
-
console.log(' Let Them Talk - Agent Bridge Dashboard v3.4.
|
|
1483
|
+
console.log(' Let Them Talk - Agent Bridge Dashboard v3.4.3');
|
|
1451
1484
|
console.log(' ============================================');
|
|
1452
1485
|
console.log(' Dashboard: http://localhost:' + PORT);
|
|
1453
1486
|
if (LAN_MODE && lanIP) {
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -18,8 +18,7 @@ const PROFILES_FILE = path.join(DATA_DIR, 'profiles.json');
|
|
|
18
18
|
const WORKFLOWS_FILE = path.join(DATA_DIR, 'workflows.json');
|
|
19
19
|
const WORKSPACES_DIR = path.join(DATA_DIR, 'workspaces');
|
|
20
20
|
const BRANCHES_FILE = path.join(DATA_DIR, 'branches.json');
|
|
21
|
-
|
|
22
|
-
const PLUGINS_DIR = path.join(DATA_DIR, 'plugins');
|
|
21
|
+
// Plugins removed in v3.4.3 — unnecessary attack surface, CLIs have their own extension systems
|
|
23
22
|
|
|
24
23
|
// In-memory state for this process
|
|
25
24
|
let registeredName = null;
|
|
@@ -418,17 +417,6 @@ function getHistoryFile(branch) {
|
|
|
418
417
|
return path.join(DATA_DIR, `branch-${sanitizeName(branch)}-history.jsonl`);
|
|
419
418
|
}
|
|
420
419
|
|
|
421
|
-
// --- Plugin helpers ---
|
|
422
|
-
|
|
423
|
-
function getPluginRegistry() {
|
|
424
|
-
if (!fs.existsSync(PLUGINS_FILE)) return [];
|
|
425
|
-
try { return JSON.parse(fs.readFileSync(PLUGINS_FILE, 'utf8')); } catch { return []; }
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
function savePluginRegistry(plugins) {
|
|
429
|
-
fs.writeFileSync(PLUGINS_FILE, JSON.stringify(plugins, null, 2));
|
|
430
|
-
}
|
|
431
|
-
|
|
432
420
|
// --- Tool implementations ---
|
|
433
421
|
|
|
434
422
|
function toolRegister(name, provider = null) {
|
|
@@ -1181,8 +1169,8 @@ function toolReset() {
|
|
|
1181
1169
|
}
|
|
1182
1170
|
}
|
|
1183
1171
|
}
|
|
1184
|
-
// Remove profiles, workflows, branches,
|
|
1185
|
-
for (const f of [PROFILES_FILE, WORKFLOWS_FILE, BRANCHES_FILE,
|
|
1172
|
+
// Remove profiles, workflows, branches, permissions, read receipts
|
|
1173
|
+
for (const f of [PROFILES_FILE, WORKFLOWS_FILE, BRANCHES_FILE, PERMISSIONS_FILE, READ_RECEIPTS_FILE]) {
|
|
1186
1174
|
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
1187
1175
|
}
|
|
1188
1176
|
// Remove workspaces dir
|
|
@@ -1497,11 +1485,6 @@ const server = new Server(
|
|
|
1497
1485
|
);
|
|
1498
1486
|
|
|
1499
1487
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
1500
|
-
const pluginTools = loadedPlugins.map(p => ({
|
|
1501
|
-
name: 'plugin_' + p.name,
|
|
1502
|
-
description: '[Plugin] ' + p.description,
|
|
1503
|
-
inputSchema: p.inputSchema,
|
|
1504
|
-
}));
|
|
1505
1488
|
return {
|
|
1506
1489
|
tools: [
|
|
1507
1490
|
{
|
|
@@ -1875,7 +1858,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1875
1858
|
properties: {},
|
|
1876
1859
|
},
|
|
1877
1860
|
},
|
|
1878
|
-
...pluginTools,
|
|
1879
1861
|
],
|
|
1880
1862
|
};
|
|
1881
1863
|
});
|
|
@@ -1969,12 +1951,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1969
1951
|
result = toolListBranches();
|
|
1970
1952
|
break;
|
|
1971
1953
|
default:
|
|
1972
|
-
// Check if it's a plugin tool
|
|
1973
|
-
if (name.startsWith('plugin_')) {
|
|
1974
|
-
const pluginName = name.substring(7);
|
|
1975
|
-
result = await executePlugin(pluginName, args);
|
|
1976
|
-
break;
|
|
1977
|
-
}
|
|
1978
1954
|
return {
|
|
1979
1955
|
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
1980
1956
|
isError: true,
|
|
@@ -2014,90 +1990,11 @@ process.on('exit', () => {
|
|
|
2014
1990
|
process.on('SIGTERM', () => process.exit(0));
|
|
2015
1991
|
process.on('SIGINT', () => process.exit(0));
|
|
2016
1992
|
|
|
2017
|
-
// --- Phase 5: Plugin system ---
|
|
2018
|
-
|
|
2019
|
-
let loadedPlugins = []; // { name, description, inputSchema, handler }
|
|
2020
|
-
|
|
2021
|
-
function loadPlugins() {
|
|
2022
|
-
loadedPlugins = [];
|
|
2023
|
-
if (!fs.existsSync(PLUGINS_DIR)) return;
|
|
2024
|
-
const registry = getPluginRegistry();
|
|
2025
|
-
const enabledNames = new Set(registry.filter(p => p.enabled !== false).map(p => p.name));
|
|
2026
|
-
|
|
2027
|
-
try {
|
|
2028
|
-
const vm = require('vm');
|
|
2029
|
-
const files = fs.readdirSync(PLUGINS_DIR).filter(f => f.endsWith('.js'));
|
|
2030
|
-
for (const file of files) {
|
|
2031
|
-
try {
|
|
2032
|
-
const pluginPath = path.join(PLUGINS_DIR, file);
|
|
2033
|
-
const code = fs.readFileSync(pluginPath, 'utf8');
|
|
2034
|
-
// Run plugin in a sandboxed VM context — no require, no process, no child_process
|
|
2035
|
-
const sandbox = { module: { exports: {} }, exports: {}, console: { log: () => {}, error: () => {}, warn: () => {} } };
|
|
2036
|
-
vm.runInNewContext(code, sandbox, { filename: file, timeout: 5000 });
|
|
2037
|
-
const plugin = sandbox.module.exports;
|
|
2038
|
-
if (!plugin.name || !plugin.description || !plugin.handler) {
|
|
2039
|
-
console.error(`Plugin ${file}: missing name, description, or handler`);
|
|
2040
|
-
continue;
|
|
2041
|
-
}
|
|
2042
|
-
if (!enabledNames.has(plugin.name) && enabledNames.size > 0) continue;
|
|
2043
|
-
loadedPlugins.push({
|
|
2044
|
-
name: plugin.name,
|
|
2045
|
-
description: plugin.description,
|
|
2046
|
-
inputSchema: plugin.inputSchema || { type: 'object', properties: {} },
|
|
2047
|
-
handler: plugin.handler,
|
|
2048
|
-
});
|
|
2049
|
-
console.error(`Plugin loaded: ${plugin.name} (sandboxed)`);
|
|
2050
|
-
} catch (e) {
|
|
2051
|
-
console.error(`Plugin ${file} failed to load: ${e.message}`);
|
|
2052
|
-
}
|
|
2053
|
-
}
|
|
2054
|
-
} catch {}
|
|
2055
|
-
}
|
|
2056
|
-
|
|
2057
|
-
function executePlugin(pluginName, args) {
|
|
2058
|
-
const plugin = loadedPlugins.find(p => p.name === pluginName);
|
|
2059
|
-
if (!plugin) return { error: `Plugin "${pluginName}" not found` };
|
|
2060
|
-
|
|
2061
|
-
const context = {
|
|
2062
|
-
registeredName,
|
|
2063
|
-
sendMessage: (to, content) => toolSendMessage(content, to),
|
|
2064
|
-
getAgents: () => toolListAgents().agents,
|
|
2065
|
-
getHistory: (limit) => toolGetHistory(limit),
|
|
2066
|
-
readFile: (filePath) => {
|
|
2067
|
-
const resolved = path.resolve(filePath);
|
|
2068
|
-
const allowedRoot = path.resolve(process.cwd());
|
|
2069
|
-
let realPath;
|
|
2070
|
-
try { realPath = fs.realpathSync(resolved); } catch { throw new Error('File not found'); }
|
|
2071
|
-
if (!realPath.startsWith(allowedRoot + path.sep) && realPath !== allowedRoot) {
|
|
2072
|
-
throw new Error('File path must be within the project directory');
|
|
2073
|
-
}
|
|
2074
|
-
return fs.readFileSync(realPath, 'utf8');
|
|
2075
|
-
},
|
|
2076
|
-
};
|
|
2077
|
-
|
|
2078
|
-
return new Promise((resolve) => {
|
|
2079
|
-
const timeout = setTimeout(() => resolve({ error: 'Plugin execution timed out (30s)' }), 30000);
|
|
2080
|
-
try {
|
|
2081
|
-
const result = plugin.handler(args, context);
|
|
2082
|
-
if (result && typeof result.then === 'function') {
|
|
2083
|
-
result.then(r => { clearTimeout(timeout); resolve(r); }).catch(e => { clearTimeout(timeout); resolve({ error: e.message }); });
|
|
2084
|
-
} else {
|
|
2085
|
-
clearTimeout(timeout);
|
|
2086
|
-
resolve(result);
|
|
2087
|
-
}
|
|
2088
|
-
} catch (e) {
|
|
2089
|
-
clearTimeout(timeout);
|
|
2090
|
-
resolve({ error: e.message });
|
|
2091
|
-
}
|
|
2092
|
-
});
|
|
2093
|
-
}
|
|
2094
|
-
|
|
2095
1993
|
async function main() {
|
|
2096
1994
|
ensureDataDir();
|
|
2097
|
-
loadPlugins();
|
|
2098
1995
|
const transport = new StdioServerTransport();
|
|
2099
1996
|
await server.connect(transport);
|
|
2100
|
-
console.error('Agent Bridge MCP server v3.4.
|
|
1997
|
+
console.error('Agent Bridge MCP server v3.4.3 running (27 tools)');
|
|
2101
1998
|
}
|
|
2102
1999
|
|
|
2103
2000
|
main().catch(console.error);
|