let-them-talk 3.4.0 → 3.4.2
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 +49 -0
- package/LICENSE +1 -1
- package/cli.js +1 -1
- package/dashboard.html +123 -36
- package/dashboard.js +153 -72
- package/package.json +1 -1
- package/server.js +80 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.4.2] - 2026-03-15
|
|
4
|
+
|
|
5
|
+
### Security — CSRF Protection
|
|
6
|
+
- Required `X-LTT-Request` custom header on all POST/PUT/DELETE requests
|
|
7
|
+
- `lttFetch` wrapper in dashboard automatically includes the header
|
|
8
|
+
- Malicious cross-origin pages cannot set custom headers without CORS preflight approval
|
|
9
|
+
- Removed wildcard `Access-Control-Allow-Origin: *` in LAN mode — now uses explicit trusted origins only
|
|
10
|
+
- Empty Origin/Referer no longer auto-trusted — requires custom header as minimum protection
|
|
11
|
+
|
|
12
|
+
### Security — LAN Auth Token
|
|
13
|
+
- Auto-generated 32-char hex token when LAN mode is enabled
|
|
14
|
+
- Token required for all non-localhost requests (via `?token=` query param or `X-LTT-Token` header)
|
|
15
|
+
- Token included in QR code URL — phone scans and it just works
|
|
16
|
+
- Token displayed in phone access modal with explanation
|
|
17
|
+
- New token generated each time LAN mode is toggled on
|
|
18
|
+
- Token persists across server restarts via `.lan-token` file
|
|
19
|
+
- Localhost access never requires a token
|
|
20
|
+
|
|
21
|
+
### Security — Content Security Policy
|
|
22
|
+
- CSP header added to dashboard HTML response
|
|
23
|
+
- `script-src 'unsafe-inline'` for inline handlers, blocks `eval()` and external scripts
|
|
24
|
+
- `connect-src 'self'` restricts API calls to same origin
|
|
25
|
+
- `font-src`, `style-src`, `img-src` scoped to required sources only
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- CSRF brace imbalance that trapped GET handlers inside POST-only block
|
|
29
|
+
- LAN token not forwarded from phone URL to API calls and SSE
|
|
30
|
+
- Redundant nested origin check collapsed to single condition
|
|
31
|
+
|
|
32
|
+
## [3.4.1] - 2026-03-15
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- **File-level mutex** — in-memory promise queue per file for serializing edit/delete operations
|
|
36
|
+
- **Agent permissions enforcement** — `canSendTo()` checks in `send_message` and `broadcast`, `can_read` filtering in `get_history` and message delivery
|
|
37
|
+
- **Read receipts** — auto-recorded when agents consume messages, visible as agent-initial dots under messages in dashboard
|
|
38
|
+
|
|
39
|
+
### Security
|
|
40
|
+
- HTTP 500 responses now return generic error instead of raw `err.message` (prevents filesystem path leaks)
|
|
41
|
+
- `/api/discover` changed from GET to POST (now under CSRF protection)
|
|
42
|
+
- `workspace_read`/`workspace_list` validate agent name parameter with regex
|
|
43
|
+
- `get_history` filters results by agent's `can_read` permissions
|
|
44
|
+
- `read_receipts.json` and `permissions.json` added to both MCP and dashboard reset cleanup
|
|
45
|
+
- Dashboard workspace API regex aligned with server (`[a-zA-Z0-9_-]`)
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
- `toolWaitForReply` missing `markAsRead` calls (read receipts not recorded)
|
|
49
|
+
- `toolBroadcast` bypassing permission checks entirely
|
|
50
|
+
- `toolReset` not cleaning up `permissions.json` and `read_receipts.json`
|
|
51
|
+
|
|
3
52
|
## [3.4.0] - 2026-03-15
|
|
4
53
|
|
|
5
54
|
### Added — Dashboard Features
|
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.2
|
|
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
package/dashboard.html
CHANGED
|
@@ -850,6 +850,27 @@
|
|
|
850
850
|
|
|
851
851
|
.msg-content a:hover { text-decoration: underline; }
|
|
852
852
|
|
|
853
|
+
/* ===== READ RECEIPTS ===== */
|
|
854
|
+
.read-receipts {
|
|
855
|
+
display: flex;
|
|
856
|
+
gap: 2px;
|
|
857
|
+
margin-top: 4px;
|
|
858
|
+
align-items: center;
|
|
859
|
+
}
|
|
860
|
+
.read-receipt-dot {
|
|
861
|
+
width: 14px;
|
|
862
|
+
height: 14px;
|
|
863
|
+
border-radius: 50%;
|
|
864
|
+
background: var(--surface-3);
|
|
865
|
+
display: flex;
|
|
866
|
+
align-items: center;
|
|
867
|
+
justify-content: center;
|
|
868
|
+
font-size: 8px;
|
|
869
|
+
font-weight: 700;
|
|
870
|
+
color: var(--text-dim);
|
|
871
|
+
cursor: default;
|
|
872
|
+
}
|
|
873
|
+
|
|
853
874
|
/* ===== MESSAGE INPUT ===== */
|
|
854
875
|
.msg-input-bar {
|
|
855
876
|
border-top: 1px solid var(--border);
|
|
@@ -2821,7 +2842,7 @@
|
|
|
2821
2842
|
</div>
|
|
2822
2843
|
</div>
|
|
2823
2844
|
<div class="app-footer">
|
|
2824
|
-
<span>Let Them Talk v3.4.
|
|
2845
|
+
<span>Let Them Talk v3.4.2</span>
|
|
2825
2846
|
</div>
|
|
2826
2847
|
<div class="profile-popup" id="profile-popup" onclick="event.stopPropagation()">
|
|
2827
2848
|
<div class="profile-popup-header">
|
|
@@ -2876,6 +2897,26 @@ var POLL_INTERVAL = 2000;
|
|
|
2876
2897
|
var SLEEP_THRESHOLD = 60; // seconds
|
|
2877
2898
|
var lastMessageCount = 0;
|
|
2878
2899
|
var autoScroll = true;
|
|
2900
|
+
// CSRF protection — all mutating requests must include this header
|
|
2901
|
+
// Read LAN token from URL on page load (phone access via QR code)
|
|
2902
|
+
var _lttToken = (function() {
|
|
2903
|
+
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 ''; }
|
|
2904
|
+
})();
|
|
2905
|
+
|
|
2906
|
+
function lttFetch(url, opts) {
|
|
2907
|
+
opts = opts || {};
|
|
2908
|
+
// Append LAN token to URL if present (needed for phone/LAN access)
|
|
2909
|
+
if (_lttToken) {
|
|
2910
|
+
var sep = url.indexOf('?') >= 0 ? '&' : '?';
|
|
2911
|
+
url = url + sep + 'token=' + encodeURIComponent(_lttToken);
|
|
2912
|
+
}
|
|
2913
|
+
if (opts.method && opts.method !== 'GET') {
|
|
2914
|
+
if (!opts.headers) opts.headers = {};
|
|
2915
|
+
opts.headers['X-LTT-Request'] = '1';
|
|
2916
|
+
}
|
|
2917
|
+
return fetch(url, opts);
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2879
2920
|
var activeThread = null;
|
|
2880
2921
|
var activeProject = ''; // empty = default/local
|
|
2881
2922
|
var cachedHistory = [];
|
|
@@ -3206,7 +3247,7 @@ function renderAgents(agents) {
|
|
|
3206
3247
|
|
|
3207
3248
|
function sendNudge(agentName) {
|
|
3208
3249
|
var body = JSON.stringify({ to: agentName, content: 'Hey ' + agentName + ', the user is waiting for you. Please check for new messages and continue your work.' });
|
|
3209
|
-
|
|
3250
|
+
lttFetch('/api/inject' + projectParam(), {
|
|
3210
3251
|
method: 'POST',
|
|
3211
3252
|
headers: { 'Content-Type': 'application/json' },
|
|
3212
3253
|
body: body
|
|
@@ -3264,7 +3305,7 @@ function doInject() {
|
|
|
3264
3305
|
if (!target || !content) return;
|
|
3265
3306
|
|
|
3266
3307
|
var body = JSON.stringify({ to: target, content: content });
|
|
3267
|
-
|
|
3308
|
+
lttFetch('/api/inject' + projectParam(), {
|
|
3268
3309
|
method: 'POST',
|
|
3269
3310
|
headers: { 'Content-Type': 'application/json' },
|
|
3270
3311
|
body: body
|
|
@@ -3448,6 +3489,7 @@ function renderMessages(messages) {
|
|
|
3448
3489
|
'</div>' +
|
|
3449
3490
|
'<div class="msg-content">' + renderMarkdown(m.content) + '</div>' +
|
|
3450
3491
|
buildReactionsHtml(m.id) +
|
|
3492
|
+
buildReadReceipts(m.id) +
|
|
3451
3493
|
'</div></div>';
|
|
3452
3494
|
lastFrom = m.from;
|
|
3453
3495
|
lastTo = m.to;
|
|
@@ -3469,6 +3511,7 @@ function renderMessages(messages) {
|
|
|
3469
3511
|
'<div class="file-meta"><span class="file-icon">📄</span>' + escapeHtml(fileName) + '<span class="file-size">' + fileSize + '</span></div>' +
|
|
3470
3512
|
'<div class="msg-content">' + renderMarkdown(m.content) + '</div>' +
|
|
3471
3513
|
buildReactionsHtml(m.id) +
|
|
3514
|
+
buildReadReceipts(m.id) +
|
|
3472
3515
|
'</div></div>';
|
|
3473
3516
|
lastFrom = m.from;
|
|
3474
3517
|
lastTo = m.to;
|
|
@@ -3486,6 +3529,7 @@ function renderMessages(messages) {
|
|
|
3486
3529
|
'</div>' +
|
|
3487
3530
|
'<div class="msg-content">' + renderMarkdown(m.content) + '</div>' +
|
|
3488
3531
|
buildReactionsHtml(m.id) +
|
|
3532
|
+
buildReadReceipts(m.id) +
|
|
3489
3533
|
'</div></div>';
|
|
3490
3534
|
lastFrom = m.from;
|
|
3491
3535
|
lastTo = m.to;
|
|
@@ -3582,6 +3626,33 @@ function onSearch() {
|
|
|
3582
3626
|
renderMessages(cachedHistory);
|
|
3583
3627
|
}
|
|
3584
3628
|
|
|
3629
|
+
// ==================== READ RECEIPTS ====================
|
|
3630
|
+
|
|
3631
|
+
var cachedReadReceipts = {};
|
|
3632
|
+
|
|
3633
|
+
function fetchReadReceipts() {
|
|
3634
|
+
var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
|
|
3635
|
+
lttFetch('/api/read-receipts' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
3636
|
+
cachedReadReceipts = data || {};
|
|
3637
|
+
}).catch(function() {});
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3640
|
+
function buildReadReceipts(msgId) {
|
|
3641
|
+
var receipts = cachedReadReceipts[msgId];
|
|
3642
|
+
if (!receipts) return '';
|
|
3643
|
+
var agents = Object.keys(receipts);
|
|
3644
|
+
if (!agents.length) return '';
|
|
3645
|
+
var html = '<div class="read-receipts">';
|
|
3646
|
+
for (var i = 0; i < agents.length; i++) {
|
|
3647
|
+
var agent = agents[i];
|
|
3648
|
+
var time = receipts[agent] ? new Date(receipts[agent]).toLocaleTimeString() : '';
|
|
3649
|
+
var color = getColor(agent);
|
|
3650
|
+
html += '<div class="read-receipt-dot" style="background:' + color + '" title="Read by ' + escapeHtml(agent) + (time ? ' at ' + time : '') + '">' + initial(agent) + '</div>';
|
|
3651
|
+
}
|
|
3652
|
+
html += '</div>';
|
|
3653
|
+
return html;
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3585
3656
|
// ==================== REACTIONS ====================
|
|
3586
3657
|
|
|
3587
3658
|
var REACTION_EMOJIS = ['\ud83d\udc4d', '\u2705', '\u2764\ufe0f', '\ud83e\udd14', '\ud83d\udd25'];
|
|
@@ -3701,7 +3772,7 @@ function deleteMessage(msgId) {
|
|
|
3701
3772
|
var preview = msg.content.substring(0, 60) + (msg.content.length > 60 ? '...' : '');
|
|
3702
3773
|
if (!confirm('Delete this message from ' + msg.from + '?\n\n"' + preview + '"')) return;
|
|
3703
3774
|
var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
|
|
3704
|
-
|
|
3775
|
+
lttFetch('/api/message' + pq, {
|
|
3705
3776
|
method: 'DELETE',
|
|
3706
3777
|
headers: { 'Content-Type': 'application/json' },
|
|
3707
3778
|
body: JSON.stringify({ id: msgId })
|
|
@@ -3751,7 +3822,7 @@ function saveEditMessage() {
|
|
|
3751
3822
|
var content = document.getElementById('edit-msg-content').value.trim();
|
|
3752
3823
|
if (!content) return;
|
|
3753
3824
|
var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
|
|
3754
|
-
|
|
3825
|
+
lttFetch('/api/message' + pq, {
|
|
3755
3826
|
method: 'PUT',
|
|
3756
3827
|
headers: { 'Content-Type': 'application/json' },
|
|
3757
3828
|
body: JSON.stringify({ id: msgId, content: content })
|
|
@@ -3943,7 +4014,7 @@ function renderAgentStats() {
|
|
|
3943
4014
|
|
|
3944
4015
|
function fetchActivity() {
|
|
3945
4016
|
var pq = projectParam();
|
|
3946
|
-
|
|
4017
|
+
lttFetch('/api/timeline' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
3947
4018
|
renderActivityHeatmap(data);
|
|
3948
4019
|
}).catch(function() {});
|
|
3949
4020
|
}
|
|
@@ -4123,7 +4194,7 @@ var cachedTasks = [];
|
|
|
4123
4194
|
|
|
4124
4195
|
function fetchTasks() {
|
|
4125
4196
|
var pq = projectParam();
|
|
4126
|
-
|
|
4197
|
+
lttFetch('/api/tasks' + pq).then(function(r) { return r.json(); }).then(function(tasks) {
|
|
4127
4198
|
cachedTasks = Array.isArray(tasks) ? tasks : [];
|
|
4128
4199
|
renderTasks();
|
|
4129
4200
|
}).catch(function() {
|
|
@@ -4214,7 +4285,7 @@ function buildTaskCard(t) {
|
|
|
4214
4285
|
}
|
|
4215
4286
|
|
|
4216
4287
|
function updateTaskStatus(taskId, newStatus) {
|
|
4217
|
-
|
|
4288
|
+
lttFetch('/api/tasks' + projectParam(), {
|
|
4218
4289
|
method: 'POST',
|
|
4219
4290
|
headers: { 'Content-Type': 'application/json' },
|
|
4220
4291
|
body: JSON.stringify({ task_id: taskId, status: newStatus })
|
|
@@ -4271,7 +4342,7 @@ var AGENT_COLORS = ['#58a6ff', '#f78166', '#7ee787', '#d2a8ff', '#ffa657', '#ff7
|
|
|
4271
4342
|
|
|
4272
4343
|
function fetchStats() {
|
|
4273
4344
|
var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
|
|
4274
|
-
|
|
4345
|
+
lttFetch('/api/stats' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
4275
4346
|
renderStats(data);
|
|
4276
4347
|
}).catch(function(e) { console.error('Stats fetch failed:', e); });
|
|
4277
4348
|
}
|
|
@@ -4520,7 +4591,7 @@ function saveProfile() {
|
|
|
4520
4591
|
};
|
|
4521
4592
|
if (avatar) body.avatar = avatar;
|
|
4522
4593
|
|
|
4523
|
-
|
|
4594
|
+
lttFetch('/api/profiles' + projectParam(), {
|
|
4524
4595
|
method: 'POST',
|
|
4525
4596
|
headers: { 'Content-Type': 'application/json' },
|
|
4526
4597
|
body: JSON.stringify(body)
|
|
@@ -4536,7 +4607,7 @@ function saveProfile() {
|
|
|
4536
4607
|
|
|
4537
4608
|
function fetchWorkspaces() {
|
|
4538
4609
|
var pq = projectParam();
|
|
4539
|
-
|
|
4610
|
+
lttFetch('/api/workspaces' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
4540
4611
|
renderWorkspaces(data);
|
|
4541
4612
|
}).catch(function() {});
|
|
4542
4613
|
}
|
|
@@ -4583,7 +4654,7 @@ function renderWorkspaces(data) {
|
|
|
4583
4654
|
|
|
4584
4655
|
function fetchWorkflows() {
|
|
4585
4656
|
var pq = projectParam();
|
|
4586
|
-
|
|
4657
|
+
lttFetch('/api/workflows' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
4587
4658
|
renderWorkflows(Array.isArray(data) ? data : []);
|
|
4588
4659
|
}).catch(function() {});
|
|
4589
4660
|
}
|
|
@@ -4634,7 +4705,7 @@ function renderWorkflows(workflows) {
|
|
|
4634
4705
|
}
|
|
4635
4706
|
|
|
4636
4707
|
function dashAdvanceWorkflow(wfId) {
|
|
4637
|
-
|
|
4708
|
+
lttFetch('/api/workflows' + projectParam(), {
|
|
4638
4709
|
method: 'POST',
|
|
4639
4710
|
headers: { 'Content-Type': 'application/json' },
|
|
4640
4711
|
body: JSON.stringify({ action: 'advance', workflow_id: wfId })
|
|
@@ -4647,7 +4718,7 @@ var activeBranch = '';
|
|
|
4647
4718
|
|
|
4648
4719
|
function fetchBranches() {
|
|
4649
4720
|
var pq = projectParam();
|
|
4650
|
-
|
|
4721
|
+
lttFetch('/api/branches' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
4651
4722
|
renderBranchTabs(data);
|
|
4652
4723
|
}).catch(function() {});
|
|
4653
4724
|
}
|
|
@@ -4683,7 +4754,7 @@ function switchBranch(name) {
|
|
|
4683
4754
|
|
|
4684
4755
|
function fetchPlugins() {
|
|
4685
4756
|
var pq = projectParam();
|
|
4686
|
-
|
|
4757
|
+
lttFetch('/api/plugins' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
4687
4758
|
renderPlugins(Array.isArray(data) ? data : []);
|
|
4688
4759
|
}).catch(function() {});
|
|
4689
4760
|
}
|
|
@@ -4710,7 +4781,7 @@ function renderPlugins(plugins) {
|
|
|
4710
4781
|
}
|
|
4711
4782
|
|
|
4712
4783
|
function togglePlugin(name) {
|
|
4713
|
-
|
|
4784
|
+
lttFetch('/api/plugins' + projectParam(), {
|
|
4714
4785
|
method: 'POST',
|
|
4715
4786
|
headers: { 'Content-Type': 'application/json' },
|
|
4716
4787
|
body: JSON.stringify({ action: 'toggle', name: name })
|
|
@@ -4725,9 +4796,9 @@ function poll() {
|
|
|
4725
4796
|
var bp = activeBranch && activeBranch !== 'main' ? '&branch=' + encodeURIComponent(activeBranch) : '';
|
|
4726
4797
|
var pollStart = Date.now();
|
|
4727
4798
|
Promise.all([
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4799
|
+
lttFetch('/api/history?limit=500' + pp + bp).then(function(r) { return r.json(); }),
|
|
4800
|
+
lttFetch('/api/agents' + pq).then(function(r) { return r.json(); }),
|
|
4801
|
+
lttFetch('/api/status' + pq).then(function(r) { return r.json(); }),
|
|
4731
4802
|
]).then(function(results) {
|
|
4732
4803
|
console.log('[LTT] poll ok — history:' + results[0].length + ' agents:' + Object.keys(results[1]).length + ' project:' + (activeProject || 'default'));
|
|
4733
4804
|
updateConnectionInfo(Date.now() - pollStart);
|
|
@@ -4774,6 +4845,7 @@ function poll() {
|
|
|
4774
4845
|
if (activeView === 'workspaces') fetchWorkspaces();
|
|
4775
4846
|
if (activeView === 'workflows') fetchWorkflows();
|
|
4776
4847
|
if (activeView === 'stats') fetchStats();
|
|
4848
|
+
fetchReadReceipts();
|
|
4777
4849
|
}).catch(function(e) {
|
|
4778
4850
|
console.error('Poll failed:', e);
|
|
4779
4851
|
document.getElementById('conn-detail').textContent = ' ERR: ' + e.message;
|
|
@@ -4782,7 +4854,7 @@ function poll() {
|
|
|
4782
4854
|
|
|
4783
4855
|
function doReset() {
|
|
4784
4856
|
if (!confirm('Clear all messages, agents, and history?')) return;
|
|
4785
|
-
|
|
4857
|
+
lttFetch('/api/reset' + projectParam(), { method: 'POST' }).then(function() {
|
|
4786
4858
|
lastMessageCount = 0;
|
|
4787
4859
|
activeThread = null;
|
|
4788
4860
|
cachedHistory = [];
|
|
@@ -4794,7 +4866,7 @@ function doReset() {
|
|
|
4794
4866
|
// ==================== PROJECT MANAGEMENT ====================
|
|
4795
4867
|
|
|
4796
4868
|
function loadProjects() {
|
|
4797
|
-
return
|
|
4869
|
+
return lttFetch('/api/projects').then(function(r) { return r.json(); }).then(function(projects) {
|
|
4798
4870
|
console.log('[LTT] loadProjects:', projects.length, 'projects, activeProject:', activeProject);
|
|
4799
4871
|
var sel = document.getElementById('project-select');
|
|
4800
4872
|
// Keep the first option (Default)
|
|
@@ -4892,7 +4964,7 @@ function addProject() {
|
|
|
4892
4964
|
var projectPath = input.value.trim();
|
|
4893
4965
|
if (!projectPath) return;
|
|
4894
4966
|
|
|
4895
|
-
|
|
4967
|
+
lttFetch('/api/projects', {
|
|
4896
4968
|
method: 'POST',
|
|
4897
4969
|
headers: { 'Content-Type': 'application/json' },
|
|
4898
4970
|
body: JSON.stringify({ path: projectPath })
|
|
@@ -4918,7 +4990,7 @@ function discoverProjects() {
|
|
|
4918
4990
|
resultsEl.style.display = 'block';
|
|
4919
4991
|
resultsEl.innerHTML = '<div style="font-size:11px;color:var(--text-muted);padding:4px;">Scanning...</div>';
|
|
4920
4992
|
|
|
4921
|
-
|
|
4993
|
+
lttFetch('/api/discover', { method: 'POST' }).then(function(r) { return r.json(); }).then(function(found) {
|
|
4922
4994
|
if (!found.length) {
|
|
4923
4995
|
resultsEl.innerHTML = '<div style="font-size:11px;color:var(--text-muted);padding:4px;">No new projects found (all discovered projects already added)</div>';
|
|
4924
4996
|
setTimeout(function() { resultsEl.style.display = 'none'; }, 3000);
|
|
@@ -4944,7 +5016,7 @@ function discoverProjects() {
|
|
|
4944
5016
|
}
|
|
4945
5017
|
|
|
4946
5018
|
function addDiscovered(projectPath, name) {
|
|
4947
|
-
|
|
5019
|
+
lttFetch('/api/projects', {
|
|
4948
5020
|
method: 'POST',
|
|
4949
5021
|
headers: { 'Content-Type': 'application/json' },
|
|
4950
5022
|
body: JSON.stringify({ path: projectPath, name: name })
|
|
@@ -4960,7 +5032,7 @@ function removeProject() {
|
|
|
4960
5032
|
if (!activeProject) return;
|
|
4961
5033
|
if (!confirm('Remove this project from the dashboard?')) return;
|
|
4962
5034
|
|
|
4963
|
-
|
|
5035
|
+
lttFetch('/api/projects', {
|
|
4964
5036
|
method: 'DELETE',
|
|
4965
5037
|
headers: { 'Content-Type': 'application/json' },
|
|
4966
5038
|
body: JSON.stringify({ path: activeProject })
|
|
@@ -5223,7 +5295,7 @@ updateNotifBtn();
|
|
|
5223
5295
|
var lanState = { lan_mode: false, lan_ip: null, port: 3000 };
|
|
5224
5296
|
|
|
5225
5297
|
function fetchLanState() {
|
|
5226
|
-
return
|
|
5298
|
+
return lttFetch('/api/server-info').then(function(r) { return r.json(); }).then(function(info) {
|
|
5227
5299
|
lanState = info;
|
|
5228
5300
|
updateLanUI();
|
|
5229
5301
|
return info;
|
|
@@ -5273,7 +5345,7 @@ function toggleLanMode() {
|
|
|
5273
5345
|
var content = document.getElementById('phone-modal-content');
|
|
5274
5346
|
content.innerHTML = '<div class="phone-off-state"><p>Switching...</p></div>';
|
|
5275
5347
|
|
|
5276
|
-
|
|
5348
|
+
lttFetch('/api/toggle-lan', { method: 'POST' })
|
|
5277
5349
|
.then(function(r) { return r.json(); })
|
|
5278
5350
|
.then(function(info) {
|
|
5279
5351
|
lanState = info;
|
|
@@ -5301,10 +5373,20 @@ function renderPhoneModalContent() {
|
|
|
5301
5373
|
}
|
|
5302
5374
|
|
|
5303
5375
|
var url = 'http://' + lanState.lan_ip + ':' + lanState.port;
|
|
5304
|
-
// Include active project so the phone shows the same view
|
|
5305
|
-
|
|
5376
|
+
// Include auth token and active project so the phone shows the same view
|
|
5377
|
+
var params = [];
|
|
5378
|
+
if (lanState.lan_token) params.push('token=' + encodeURIComponent(lanState.lan_token));
|
|
5379
|
+
if (activeProject) params.push('project=' + encodeURIComponent(activeProject));
|
|
5380
|
+
if (params.length) url += '?' + params.join('&');
|
|
5306
5381
|
var qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&color=58a6ff&bgcolor=0d1117&data=' + encodeURIComponent(url);
|
|
5307
5382
|
|
|
5383
|
+
var tokenHtml = lanState.lan_token ?
|
|
5384
|
+
'<div style="margin-top:12px;background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:10px">' +
|
|
5385
|
+
'<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px">Auth Token</div>' +
|
|
5386
|
+
'<div style="font-family:monospace;font-size:13px;color:var(--accent);word-break:break-all">' + escapeHtml(lanState.lan_token) + '</div>' +
|
|
5387
|
+
'<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>' +
|
|
5388
|
+
'</div>' : '';
|
|
5389
|
+
|
|
5308
5390
|
el.innerHTML =
|
|
5309
5391
|
'<div class="phone-url-box">' +
|
|
5310
5392
|
'<div class="phone-url-text">' + escapeHtml(url) + '</div>' +
|
|
@@ -5313,12 +5395,16 @@ function renderPhoneModalContent() {
|
|
|
5313
5395
|
'<div class="phone-qr">' +
|
|
5314
5396
|
'<img src="' + qrUrl + '" alt="QR Code" onerror="this.parentElement.style.display=\'none\'">' +
|
|
5315
5397
|
'</div>' +
|
|
5316
|
-
'<div class="phone-qr-hint">Scan with your phone camera on the same WiFi</div>'
|
|
5398
|
+
'<div class="phone-qr-hint">Scan with your phone camera on the same WiFi</div>' +
|
|
5399
|
+
tokenHtml;
|
|
5317
5400
|
}
|
|
5318
5401
|
|
|
5319
5402
|
function copyPhoneUrl() {
|
|
5320
5403
|
var url = 'http://' + lanState.lan_ip + ':' + lanState.port;
|
|
5321
|
-
|
|
5404
|
+
var params = [];
|
|
5405
|
+
if (lanState.lan_token) params.push('token=' + encodeURIComponent(lanState.lan_token));
|
|
5406
|
+
if (activeProject) params.push('project=' + encodeURIComponent(activeProject));
|
|
5407
|
+
if (params.length) url += '?' + params.join('&');
|
|
5322
5408
|
navigator.clipboard.writeText(url).then(function() {
|
|
5323
5409
|
var btn = document.querySelector('.phone-url-copy');
|
|
5324
5410
|
btn.textContent = 'Copied!';
|
|
@@ -5337,7 +5423,7 @@ var selectedCli = 'claude';
|
|
|
5337
5423
|
function renderLaunchPanel() {
|
|
5338
5424
|
var el = document.getElementById('launch-area');
|
|
5339
5425
|
// Fetch templates
|
|
5340
|
-
|
|
5426
|
+
lttFetch('/api/templates').then(function(r) { return r.json(); }).then(function(templates) {
|
|
5341
5427
|
launchTemplates = templates;
|
|
5342
5428
|
var templateOpts = '<option value="">-- No template --</option>';
|
|
5343
5429
|
for (var i = 0; i < templates.length; i++) {
|
|
@@ -5384,8 +5470,8 @@ function renderLaunchPanel() {
|
|
|
5384
5470
|
}
|
|
5385
5471
|
|
|
5386
5472
|
// ==================== v3.4: CONVERSATION TEMPLATES ====================
|
|
5387
|
-
function renderConversationTemplates(p){
|
|
5388
|
-
function showConvTemplate(tid){var pq=activeProject?'?project='+encodeURIComponent(activeProject):'';
|
|
5473
|
+
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(){})}
|
|
5474
|
+
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'})})}
|
|
5389
5475
|
|
|
5390
5476
|
function selectCli(cli) {
|
|
5391
5477
|
selectedCli = cli;
|
|
@@ -5419,7 +5505,7 @@ function doLaunch() {
|
|
|
5419
5505
|
var prompt = document.getElementById('launch-prompt').value.trim();
|
|
5420
5506
|
var resultEl = document.getElementById('launch-result');
|
|
5421
5507
|
|
|
5422
|
-
|
|
5508
|
+
lttFetch('/api/launch', {
|
|
5423
5509
|
method: 'POST',
|
|
5424
5510
|
headers: { 'Content-Type': 'application/json' },
|
|
5425
5511
|
body: JSON.stringify({ cli: selectedCli, project_dir: projectDir || undefined, agent_name: agentName, prompt: prompt || undefined })
|
|
@@ -5487,7 +5573,8 @@ function setConnStatus(status) {
|
|
|
5487
5573
|
|
|
5488
5574
|
function initSSE() {
|
|
5489
5575
|
try {
|
|
5490
|
-
var
|
|
5576
|
+
var sseUrl = '/api/events' + (_lttToken ? '?token=' + encodeURIComponent(_lttToken) : '');
|
|
5577
|
+
var eventSource = new EventSource(sseUrl);
|
|
5491
5578
|
eventSource.onmessage = function(e) {
|
|
5492
5579
|
if (e.data === 'update' || e.data === 'connected') {
|
|
5493
5580
|
poll();
|
package/dashboard.js
CHANGED
|
@@ -5,10 +5,39 @@ const path = require('path');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const { spawn } = require('child_process');
|
|
7
7
|
|
|
8
|
+
// --- File-level mutex for serializing read-then-write operations ---
|
|
9
|
+
const lockMap = new Map();
|
|
10
|
+
function withFileLock(filePath, fn) {
|
|
11
|
+
const prev = lockMap.get(filePath) || Promise.resolve();
|
|
12
|
+
const next = prev.then(fn, fn);
|
|
13
|
+
lockMap.set(filePath, next.then(() => {}, () => {}));
|
|
14
|
+
return next;
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
const PORT = parseInt(process.env.AGENT_BRIDGE_PORT || '3000', 10);
|
|
9
18
|
const LAN_STATE_FILE = path.join(__dirname, '.lan-mode');
|
|
10
19
|
let LAN_MODE = process.env.AGENT_BRIDGE_LAN === 'true' || (fs.existsSync(LAN_STATE_FILE) && fs.readFileSync(LAN_STATE_FILE, 'utf8').trim() === 'true');
|
|
11
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
|
+
|
|
12
41
|
function persistLanMode() {
|
|
13
42
|
try { fs.writeFileSync(LAN_STATE_FILE, LAN_MODE ? 'true' : 'false'); } catch {}
|
|
14
43
|
}
|
|
@@ -299,7 +328,7 @@ function apiStats(query) {
|
|
|
299
328
|
function apiReset(query) {
|
|
300
329
|
const projectPath = query.get('project') || null;
|
|
301
330
|
const dataDir = resolveDataDir(projectPath);
|
|
302
|
-
const fixedFiles = ['messages.jsonl', 'history.jsonl', 'agents.json', 'acks.json', 'tasks.json', 'profiles.json', 'workflows.json', 'branches.json', 'plugins.json'];
|
|
331
|
+
const fixedFiles = ['messages.jsonl', 'history.jsonl', 'agents.json', 'acks.json', 'tasks.json', 'profiles.json', 'workflows.json', 'branches.json', 'plugins.json', 'read_receipts.json', 'permissions.json'];
|
|
303
332
|
for (const f of fixedFiles) {
|
|
304
333
|
const p = path.join(dataDir, f);
|
|
305
334
|
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
@@ -734,7 +763,7 @@ function apiLaunchAgent(body) {
|
|
|
734
763
|
}
|
|
735
764
|
|
|
736
765
|
// --- v3.4: Message Edit ---
|
|
737
|
-
function apiEditMessage(body, query) {
|
|
766
|
+
async function apiEditMessage(body, query) {
|
|
738
767
|
const projectPath = query.get('project') || null;
|
|
739
768
|
const { id, content } = body;
|
|
740
769
|
if (!id || !content) return { error: 'Missing "id" and/or "content" fields' };
|
|
@@ -747,36 +776,17 @@ function apiEditMessage(body, query) {
|
|
|
747
776
|
let found = false;
|
|
748
777
|
const now = new Date().toISOString();
|
|
749
778
|
|
|
750
|
-
// Update in history.jsonl
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
try {
|
|
755
|
-
const msg = JSON.parse(line);
|
|
756
|
-
if (msg.id === id) {
|
|
757
|
-
found = true;
|
|
758
|
-
if (!msg.edit_history) msg.edit_history = [];
|
|
759
|
-
msg.edit_history.push({ content: msg.content, edited_at: now });
|
|
760
|
-
msg.content = content;
|
|
761
|
-
msg.edited = true;
|
|
762
|
-
msg.edited_at = now;
|
|
763
|
-
return JSON.stringify(msg);
|
|
764
|
-
}
|
|
765
|
-
return line;
|
|
766
|
-
} catch { return line; }
|
|
767
|
-
});
|
|
768
|
-
if (found) fs.writeFileSync(historyFile, updated.join('\n') + '\n');
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
// Also update in messages.jsonl (for agents that haven't consumed yet)
|
|
772
|
-
if (found && fs.existsSync(messagesFile)) {
|
|
773
|
-
const raw = fs.readFileSync(messagesFile, 'utf8').trim();
|
|
774
|
-
if (raw) {
|
|
775
|
-
const lines = raw.split('\n');
|
|
779
|
+
// Update in history.jsonl (locked)
|
|
780
|
+
await withFileLock(historyFile, () => {
|
|
781
|
+
if (fs.existsSync(historyFile)) {
|
|
782
|
+
const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n').filter(Boolean);
|
|
776
783
|
const updated = lines.map(line => {
|
|
777
784
|
try {
|
|
778
785
|
const msg = JSON.parse(line);
|
|
779
786
|
if (msg.id === id) {
|
|
787
|
+
found = true;
|
|
788
|
+
if (!msg.edit_history) msg.edit_history = [];
|
|
789
|
+
msg.edit_history.push({ content: msg.content, edited_at: now });
|
|
780
790
|
msg.content = content;
|
|
781
791
|
msg.edited = true;
|
|
782
792
|
msg.edited_at = now;
|
|
@@ -785,8 +795,33 @@ function apiEditMessage(body, query) {
|
|
|
785
795
|
return line;
|
|
786
796
|
} catch { return line; }
|
|
787
797
|
});
|
|
788
|
-
fs.writeFileSync(
|
|
798
|
+
if (found) fs.writeFileSync(historyFile, updated.join('\n') + '\n');
|
|
789
799
|
}
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// Also update in messages.jsonl (locked independently)
|
|
803
|
+
if (found) {
|
|
804
|
+
await withFileLock(messagesFile, () => {
|
|
805
|
+
if (fs.existsSync(messagesFile)) {
|
|
806
|
+
const raw = fs.readFileSync(messagesFile, 'utf8').trim();
|
|
807
|
+
if (raw) {
|
|
808
|
+
const lines = raw.split('\n');
|
|
809
|
+
const updated = lines.map(line => {
|
|
810
|
+
try {
|
|
811
|
+
const msg = JSON.parse(line);
|
|
812
|
+
if (msg.id === id) {
|
|
813
|
+
msg.content = content;
|
|
814
|
+
msg.edited = true;
|
|
815
|
+
msg.edited_at = now;
|
|
816
|
+
return JSON.stringify(msg);
|
|
817
|
+
}
|
|
818
|
+
return line;
|
|
819
|
+
} catch { return line; }
|
|
820
|
+
});
|
|
821
|
+
fs.writeFileSync(messagesFile, updated.join('\n') + '\n');
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
});
|
|
790
825
|
}
|
|
791
826
|
|
|
792
827
|
if (!found) return { error: 'Message not found' };
|
|
@@ -794,7 +829,7 @@ function apiEditMessage(body, query) {
|
|
|
794
829
|
}
|
|
795
830
|
|
|
796
831
|
// --- v3.4: Message Delete ---
|
|
797
|
-
function apiDeleteMessage(body, query) {
|
|
832
|
+
async function apiDeleteMessage(body, query) {
|
|
798
833
|
const projectPath = query.get('project') || null;
|
|
799
834
|
const { id } = body;
|
|
800
835
|
if (!id) return { error: 'Missing "id" field' };
|
|
@@ -806,16 +841,28 @@ function apiDeleteMessage(body, query) {
|
|
|
806
841
|
let found = false;
|
|
807
842
|
let msgFrom = null;
|
|
808
843
|
|
|
809
|
-
// Find the message
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
844
|
+
// Find the message and remove from history.jsonl (locked)
|
|
845
|
+
await withFileLock(historyFile, () => {
|
|
846
|
+
if (fs.existsSync(historyFile)) {
|
|
847
|
+
const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n');
|
|
848
|
+
for (const line of lines) {
|
|
849
|
+
try {
|
|
850
|
+
const msg = JSON.parse(line);
|
|
851
|
+
if (msg.id === id) { found = true; msgFrom = msg.from; break; }
|
|
852
|
+
} catch {}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (found) {
|
|
856
|
+
const allowed = ['Dashboard', 'dashboard', 'system', '__system__'];
|
|
857
|
+
if (allowed.includes(msgFrom)) {
|
|
858
|
+
const filtered = lines.filter(line => {
|
|
859
|
+
try { return JSON.parse(line).id !== id; } catch { return true; }
|
|
860
|
+
});
|
|
861
|
+
fs.writeFileSync(historyFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
|
|
862
|
+
}
|
|
863
|
+
}
|
|
817
864
|
}
|
|
818
|
-
}
|
|
865
|
+
});
|
|
819
866
|
|
|
820
867
|
if (!found) return { error: 'Message not found' };
|
|
821
868
|
|
|
@@ -825,23 +872,16 @@ function apiDeleteMessage(body, query) {
|
|
|
825
872
|
return { error: 'Can only delete messages sent from Dashboard or system' };
|
|
826
873
|
}
|
|
827
874
|
|
|
828
|
-
// Remove from
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
if (fs.existsSync(messagesFile)) {
|
|
839
|
-
const lines = fs.readFileSync(messagesFile, 'utf8').trim().split('\n');
|
|
840
|
-
const filtered = lines.filter(line => {
|
|
841
|
-
try { return JSON.parse(line).id !== id; } catch { return true; }
|
|
842
|
-
});
|
|
843
|
-
fs.writeFileSync(messagesFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
|
|
844
|
-
}
|
|
875
|
+
// Remove from messages.jsonl (locked independently)
|
|
876
|
+
await withFileLock(messagesFile, () => {
|
|
877
|
+
if (fs.existsSync(messagesFile)) {
|
|
878
|
+
const lines = fs.readFileSync(messagesFile, 'utf8').trim().split('\n');
|
|
879
|
+
const filtered = lines.filter(line => {
|
|
880
|
+
try { return JSON.parse(line).id !== id; } catch { return true; }
|
|
881
|
+
});
|
|
882
|
+
fs.writeFileSync(messagesFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
|
|
883
|
+
}
|
|
884
|
+
});
|
|
845
885
|
|
|
846
886
|
return { success: true, id };
|
|
847
887
|
}
|
|
@@ -989,13 +1029,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
989
1029
|
|
|
990
1030
|
const allowedOrigin = `http://localhost:${PORT}`;
|
|
991
1031
|
const reqOrigin = req.headers.origin;
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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)) {
|
|
995
1037
|
res.setHeader('Access-Control-Allow-Origin', reqOrigin);
|
|
996
1038
|
}
|
|
997
1039
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
998
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
1040
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-LTT-Request, X-LTT-Token');
|
|
999
1041
|
|
|
1000
1042
|
if (req.method === 'OPTIONS') {
|
|
1001
1043
|
res.writeHead(204);
|
|
@@ -1003,7 +1045,23 @@ const server = http.createServer(async (req, res) => {
|
|
|
1003
1045
|
return;
|
|
1004
1046
|
}
|
|
1005
1047
|
|
|
1006
|
-
//
|
|
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
|
|
1007
1065
|
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
|
|
1008
1066
|
// Check Host header to block DNS rebinding attacks
|
|
1009
1067
|
const host = (req.headers.host || '').replace(/:\d+$/, '');
|
|
@@ -1014,13 +1072,26 @@ const server = http.createServer(async (req, res) => {
|
|
|
1014
1072
|
res.end(JSON.stringify({ error: 'Forbidden: invalid host' }));
|
|
1015
1073
|
return;
|
|
1016
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
|
+
}
|
|
1017
1082
|
// Check Origin header to block cross-site requests
|
|
1083
|
+
// Empty origin is NOT trusted — requires at least the custom header (checked above)
|
|
1018
1084
|
const origin = req.headers.origin || '';
|
|
1019
1085
|
const referer = req.headers.referer || '';
|
|
1020
1086
|
const source = origin || referer;
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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) {
|
|
1024
1095
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1025
1096
|
res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
|
|
1026
1097
|
return;
|
|
@@ -1054,6 +1125,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1054
1125
|
const html = fs.readFileSync(HTML_FILE, 'utf8');
|
|
1055
1126
|
res.writeHead(200, {
|
|
1056
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'",
|
|
1057
1129
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
1058
1130
|
'Pragma': 'no-cache',
|
|
1059
1131
|
'Expires': '0'
|
|
@@ -1121,7 +1193,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1121
1193
|
});
|
|
1122
1194
|
res.end(html);
|
|
1123
1195
|
}
|
|
1124
|
-
else if (url.pathname === '/api/discover' && req.method === '
|
|
1196
|
+
else if (url.pathname === '/api/discover' && req.method === 'POST') {
|
|
1125
1197
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1126
1198
|
res.end(JSON.stringify(apiDiscover()));
|
|
1127
1199
|
}
|
|
@@ -1153,7 +1225,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1153
1225
|
else if (url.pathname === '/api/workspaces' && req.method === 'GET') {
|
|
1154
1226
|
const projectPath = url.searchParams.get('project') || null;
|
|
1155
1227
|
const agentParam = url.searchParams.get('agent');
|
|
1156
|
-
if (agentParam && !/^[a-zA-Z0-
|
|
1228
|
+
if (agentParam && !/^[a-zA-Z0-9_-]{1,20}$/.test(agentParam)) {
|
|
1157
1229
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1158
1230
|
res.end(JSON.stringify({ error: 'Invalid agent name' }));
|
|
1159
1231
|
return;
|
|
@@ -1258,14 +1330,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
1258
1330
|
// --- v3.4: Message Edit ---
|
|
1259
1331
|
else if (url.pathname === '/api/message' && req.method === 'PUT') {
|
|
1260
1332
|
const body = await parseBody(req);
|
|
1261
|
-
const result = apiEditMessage(body, url.searchParams);
|
|
1333
|
+
const result = await apiEditMessage(body, url.searchParams);
|
|
1262
1334
|
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1263
1335
|
res.end(JSON.stringify(result));
|
|
1264
1336
|
}
|
|
1265
1337
|
// --- v3.4: Message Delete ---
|
|
1266
1338
|
else if (url.pathname === '/api/message' && req.method === 'DELETE') {
|
|
1267
1339
|
const body = await parseBody(req);
|
|
1268
|
-
const result = apiDeleteMessage(body, url.searchParams);
|
|
1340
|
+
const result = await apiDeleteMessage(body, url.searchParams);
|
|
1269
1341
|
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1270
1342
|
res.end(JSON.stringify(result));
|
|
1271
1343
|
}
|
|
@@ -1292,10 +1364,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
1292
1364
|
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1293
1365
|
res.end(JSON.stringify(result));
|
|
1294
1366
|
}
|
|
1367
|
+
// --- v3.4: Read Receipts ---
|
|
1368
|
+
else if (url.pathname === '/api/read-receipts' && req.method === 'GET') {
|
|
1369
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1370
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1371
|
+
res.end(JSON.stringify(readJson(filePath('read_receipts.json', projectPath))));
|
|
1372
|
+
}
|
|
1295
1373
|
// Server info (LAN mode detection for frontend)
|
|
1296
1374
|
else if (url.pathname === '/api/server-info' && req.method === 'GET') {
|
|
1297
1375
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1298
|
-
res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT }));
|
|
1376
|
+
res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT, lan_token: LAN_MODE ? LAN_TOKEN : null }));
|
|
1299
1377
|
}
|
|
1300
1378
|
// Toggle LAN mode (re-bind server live)
|
|
1301
1379
|
else if (url.pathname === '/api/toggle-lan' && req.method === 'POST') {
|
|
@@ -1303,9 +1381,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1303
1381
|
const lanIP = getLanIP();
|
|
1304
1382
|
LAN_MODE = newMode;
|
|
1305
1383
|
persistLanMode();
|
|
1384
|
+
// Regenerate token when enabling LAN mode
|
|
1385
|
+
if (newMode) generateLanToken();
|
|
1306
1386
|
// Send response first
|
|
1307
1387
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1308
|
-
res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT }));
|
|
1388
|
+
res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT, lan_token: newMode ? LAN_TOKEN : null }));
|
|
1309
1389
|
// Re-bind by stopping the listener and immediately re-listening
|
|
1310
1390
|
// Use setImmediate to let the response flush first
|
|
1311
1391
|
setImmediate(() => {
|
|
@@ -1367,8 +1447,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1367
1447
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
1368
1448
|
}
|
|
1369
1449
|
} catch (err) {
|
|
1450
|
+
console.error('Server error:', err.message);
|
|
1370
1451
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1371
|
-
res.end(JSON.stringify({ error:
|
|
1452
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
1372
1453
|
}
|
|
1373
1454
|
});
|
|
1374
1455
|
|
|
@@ -1420,7 +1501,7 @@ server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
|
|
|
1420
1501
|
const dataDir = resolveDataDir();
|
|
1421
1502
|
const lanIP = getLanIP();
|
|
1422
1503
|
console.log('');
|
|
1423
|
-
console.log(' Let Them Talk - Agent Bridge Dashboard v3.4.
|
|
1504
|
+
console.log(' Let Them Talk - Agent Bridge Dashboard v3.4.2');
|
|
1424
1505
|
console.log(' ============================================');
|
|
1425
1506
|
console.log(' Dashboard: http://localhost:' + PORT);
|
|
1426
1507
|
if (LAN_MODE && lanIP) {
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -273,14 +273,61 @@ function autoCompact() {
|
|
|
273
273
|
} catch {}
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
// --- Permissions helpers ---
|
|
277
|
+
const PERMISSIONS_FILE = path.join(DATA_DIR, 'permissions.json');
|
|
278
|
+
|
|
279
|
+
function getPermissions() {
|
|
280
|
+
if (!fs.existsSync(PERMISSIONS_FILE)) return {};
|
|
281
|
+
try { return JSON.parse(fs.readFileSync(PERMISSIONS_FILE, 'utf8')); } catch { return {}; }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function canSendTo(sender, recipient) {
|
|
285
|
+
const perms = getPermissions();
|
|
286
|
+
// If no permissions set, allow everything (backward compatible)
|
|
287
|
+
if (!perms[sender] && !perms[recipient]) return true;
|
|
288
|
+
// Check sender's write permissions
|
|
289
|
+
if (perms[sender] && perms[sender].can_write_to) {
|
|
290
|
+
const allowed = perms[sender].can_write_to;
|
|
291
|
+
if (allowed !== '*' && Array.isArray(allowed) && !allowed.includes(recipient)) return false;
|
|
292
|
+
}
|
|
293
|
+
// Check recipient's read permissions
|
|
294
|
+
if (perms[recipient] && perms[recipient].can_read) {
|
|
295
|
+
const allowed = perms[recipient].can_read;
|
|
296
|
+
if (allowed !== '*' && Array.isArray(allowed) && !allowed.includes(sender)) return false;
|
|
297
|
+
}
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// --- Read receipts helpers ---
|
|
302
|
+
const READ_RECEIPTS_FILE = path.join(DATA_DIR, 'read_receipts.json');
|
|
303
|
+
|
|
304
|
+
function getReadReceipts() {
|
|
305
|
+
if (!fs.existsSync(READ_RECEIPTS_FILE)) return {};
|
|
306
|
+
try { return JSON.parse(fs.readFileSync(READ_RECEIPTS_FILE, 'utf8')); } catch { return {}; }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function markAsRead(agentName, messageId) {
|
|
310
|
+
ensureDataDir();
|
|
311
|
+
const receipts = getReadReceipts();
|
|
312
|
+
if (!receipts[messageId]) receipts[messageId] = {};
|
|
313
|
+
receipts[messageId][agentName] = new Date().toISOString();
|
|
314
|
+
fs.writeFileSync(READ_RECEIPTS_FILE, JSON.stringify(receipts, null, 2));
|
|
315
|
+
}
|
|
316
|
+
|
|
276
317
|
// Get unconsumed messages for an agent (full scan — used by check_messages and initial load)
|
|
277
318
|
function getUnconsumedMessages(agentName, fromFilter = null) {
|
|
278
319
|
const messages = readJsonl(getMessagesFile(currentBranch));
|
|
279
320
|
const consumed = getConsumedIds(agentName);
|
|
321
|
+
const perms = getPermissions();
|
|
280
322
|
return messages.filter(m => {
|
|
281
323
|
if (m.to !== agentName) return false;
|
|
282
324
|
if (consumed.has(m.id)) return false;
|
|
283
325
|
if (fromFilter && m.from !== fromFilter && !m.system) return false;
|
|
326
|
+
// Permission check: skip messages from senders this agent can't read
|
|
327
|
+
if (perms[agentName] && perms[agentName].can_read) {
|
|
328
|
+
const allowed = perms[agentName].can_read;
|
|
329
|
+
if (allowed !== '*' && Array.isArray(allowed) && !allowed.includes(m.from) && !m.system) return false;
|
|
330
|
+
}
|
|
284
331
|
return true;
|
|
285
332
|
});
|
|
286
333
|
}
|
|
@@ -520,6 +567,11 @@ function toolSendMessage(content, to = null, reply_to = null) {
|
|
|
520
567
|
return { error: 'Cannot send a message to yourself' };
|
|
521
568
|
}
|
|
522
569
|
|
|
570
|
+
// Permission check
|
|
571
|
+
if (!canSendTo(registeredName, to)) {
|
|
572
|
+
return { error: `Permission denied: you are not allowed to send messages to "${to}"` };
|
|
573
|
+
}
|
|
574
|
+
|
|
523
575
|
const sizeErr = validateContentSize(content);
|
|
524
576
|
if (sizeErr) return sizeErr;
|
|
525
577
|
|
|
@@ -583,7 +635,9 @@ function toolBroadcast(content) {
|
|
|
583
635
|
|
|
584
636
|
ensureDataDir();
|
|
585
637
|
const ids = [];
|
|
638
|
+
const skipped = [];
|
|
586
639
|
for (const to of otherAgents) {
|
|
640
|
+
if (!canSendTo(registeredName, to)) { skipped.push(to); continue; }
|
|
587
641
|
messageSeq++;
|
|
588
642
|
const msg = {
|
|
589
643
|
id: generateId(),
|
|
@@ -600,7 +654,9 @@ function toolBroadcast(content) {
|
|
|
600
654
|
}
|
|
601
655
|
touchActivity();
|
|
602
656
|
|
|
603
|
-
|
|
657
|
+
const result = { success: true, sent_to: ids, recipient_count: ids.length };
|
|
658
|
+
if (skipped.length > 0) result.skipped = skipped;
|
|
659
|
+
return result;
|
|
604
660
|
}
|
|
605
661
|
|
|
606
662
|
async function toolWaitForReply(timeoutSeconds = 300, from = null) {
|
|
@@ -618,6 +674,7 @@ async function toolWaitForReply(timeoutSeconds = 300, from = null) {
|
|
|
618
674
|
const consumed = getConsumedIds(registeredName);
|
|
619
675
|
consumed.add(msg.id);
|
|
620
676
|
saveConsumedIds(registeredName, consumed);
|
|
677
|
+
markAsRead(registeredName, msg.id);
|
|
621
678
|
const _mf1 = getMessagesFile(currentBranch);
|
|
622
679
|
if (fs.existsSync(_mf1)) {
|
|
623
680
|
lastReadOffset = fs.statSync(_mf1).size;
|
|
@@ -647,6 +704,7 @@ async function toolWaitForReply(timeoutSeconds = 300, from = null) {
|
|
|
647
704
|
|
|
648
705
|
consumed.add(msg.id);
|
|
649
706
|
saveConsumedIds(registeredName, consumed);
|
|
707
|
+
markAsRead(registeredName, msg.id);
|
|
650
708
|
touchActivity();
|
|
651
709
|
setListening(false);
|
|
652
710
|
return buildMessageResponse(msg, consumed);
|
|
@@ -718,6 +776,7 @@ async function toolListen(from = null) {
|
|
|
718
776
|
const consumed = getConsumedIds(registeredName);
|
|
719
777
|
consumed.add(msg.id);
|
|
720
778
|
saveConsumedIds(registeredName, consumed);
|
|
779
|
+
markAsRead(registeredName, msg.id);
|
|
721
780
|
const _mfL1 = getMessagesFile(currentBranch);
|
|
722
781
|
if (fs.existsSync(_mfL1)) {
|
|
723
782
|
lastReadOffset = fs.statSync(_mfL1).size;
|
|
@@ -750,6 +809,7 @@ async function toolListen(from = null) {
|
|
|
750
809
|
|
|
751
810
|
consumed.add(msg.id);
|
|
752
811
|
saveConsumedIds(registeredName, consumed);
|
|
812
|
+
markAsRead(registeredName, msg.id);
|
|
753
813
|
touchActivity();
|
|
754
814
|
setListening(false);
|
|
755
815
|
return buildMessageResponse(msg, consumed);
|
|
@@ -776,6 +836,7 @@ async function toolListenCodex(from = null) {
|
|
|
776
836
|
const consumed = getConsumedIds(registeredName);
|
|
777
837
|
consumed.add(msg.id);
|
|
778
838
|
saveConsumedIds(registeredName, consumed);
|
|
839
|
+
markAsRead(registeredName, msg.id);
|
|
779
840
|
const _mfC1 = getMessagesFile(currentBranch);
|
|
780
841
|
if (fs.existsSync(_mfC1)) {
|
|
781
842
|
lastReadOffset = fs.statSync(_mfC1).size;
|
|
@@ -804,6 +865,7 @@ async function toolListenCodex(from = null) {
|
|
|
804
865
|
|
|
805
866
|
consumed.add(msg.id);
|
|
806
867
|
saveConsumedIds(registeredName, consumed);
|
|
868
|
+
markAsRead(registeredName, msg.id);
|
|
807
869
|
touchActivity();
|
|
808
870
|
setListening(false);
|
|
809
871
|
return buildMessageResponse(msg, consumed);
|
|
@@ -824,6 +886,16 @@ function toolGetHistory(limit = 50, thread_id = null) {
|
|
|
824
886
|
if (thread_id) {
|
|
825
887
|
history = history.filter(m => m.thread_id === thread_id || m.id === thread_id);
|
|
826
888
|
}
|
|
889
|
+
// Filter by permissions — only show messages involving this agent or permitted senders
|
|
890
|
+
if (registeredName) {
|
|
891
|
+
const perms = getPermissions();
|
|
892
|
+
if (perms[registeredName] && perms[registeredName].can_read) {
|
|
893
|
+
const allowed = perms[registeredName].can_read;
|
|
894
|
+
if (allowed !== '*' && Array.isArray(allowed)) {
|
|
895
|
+
history = history.filter(m => m.from === registeredName || m.to === registeredName || allowed.includes(m.from));
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
827
899
|
const recent = history.slice(-limit);
|
|
828
900
|
const acks = getAcks();
|
|
829
901
|
|
|
@@ -1109,8 +1181,8 @@ function toolReset() {
|
|
|
1109
1181
|
}
|
|
1110
1182
|
}
|
|
1111
1183
|
}
|
|
1112
|
-
// Remove profiles, workflows, branches, plugins
|
|
1113
|
-
for (const f of [PROFILES_FILE, WORKFLOWS_FILE, BRANCHES_FILE, PLUGINS_FILE]) {
|
|
1184
|
+
// Remove profiles, workflows, branches, plugins, permissions, read receipts
|
|
1185
|
+
for (const f of [PROFILES_FILE, WORKFLOWS_FILE, BRANCHES_FILE, PLUGINS_FILE, PERMISSIONS_FILE, READ_RECEIPTS_FILE]) {
|
|
1114
1186
|
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
1115
1187
|
}
|
|
1116
1188
|
// Remove workspaces dir
|
|
@@ -1186,6 +1258,9 @@ function toolWorkspaceWrite(key, content) {
|
|
|
1186
1258
|
function toolWorkspaceRead(key, agent) {
|
|
1187
1259
|
if (!registeredName) return { error: 'You must call register() first' };
|
|
1188
1260
|
const targetAgent = agent || registeredName;
|
|
1261
|
+
if (targetAgent !== registeredName && !/^[a-zA-Z0-9_-]{1,20}$/.test(targetAgent)) {
|
|
1262
|
+
return { error: 'Invalid agent name' };
|
|
1263
|
+
}
|
|
1189
1264
|
|
|
1190
1265
|
const ws = getWorkspace(targetAgent);
|
|
1191
1266
|
if (key) {
|
|
@@ -1203,6 +1278,7 @@ function toolWorkspaceRead(key, agent) {
|
|
|
1203
1278
|
function toolWorkspaceList(agent) {
|
|
1204
1279
|
const agents = getAgents();
|
|
1205
1280
|
if (agent) {
|
|
1281
|
+
if (!/^[a-zA-Z0-9_-]{1,20}$/.test(agent)) return { error: 'Invalid agent name' };
|
|
1206
1282
|
const ws = getWorkspace(agent);
|
|
1207
1283
|
return { agent, keys: Object.keys(ws).map(k => ({ key: k, size: ws[k].content.length, updated_at: ws[k].updated_at })) };
|
|
1208
1284
|
}
|
|
@@ -2021,7 +2097,7 @@ async function main() {
|
|
|
2021
2097
|
loadPlugins();
|
|
2022
2098
|
const transport = new StdioServerTransport();
|
|
2023
2099
|
await server.connect(transport);
|
|
2024
|
-
console.error('Agent Bridge MCP server v3.4.
|
|
2100
|
+
console.error('Agent Bridge MCP server v3.4.2 running (' + (27 + loadedPlugins.length) + ' tools)');
|
|
2025
2101
|
}
|
|
2026
2102
|
|
|
2027
2103
|
main().catch(console.error);
|