let-them-talk 3.4.0 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/LICENSE +1 -1
- package/cli.js +1 -1
- package/dashboard.html +54 -2
- package/dashboard.js +89 -62
- package/package.json +1 -1
- package/server.js +80 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.4.1] - 2026-03-15
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **File-level mutex** — in-memory promise queue per file for serializing edit/delete operations
|
|
7
|
+
- **Agent permissions enforcement** — `canSendTo()` checks in `send_message` and `broadcast`, `can_read` filtering in `get_history` and message delivery
|
|
8
|
+
- **Read receipts** — auto-recorded when agents consume messages, visible as agent-initial dots under messages in dashboard
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
- HTTP 500 responses now return generic error instead of raw `err.message` (prevents filesystem path leaks)
|
|
12
|
+
- `/api/discover` changed from GET to POST (now under CSRF protection)
|
|
13
|
+
- `workspace_read`/`workspace_list` validate agent name parameter with regex
|
|
14
|
+
- `get_history` filters results by agent's `can_read` permissions
|
|
15
|
+
- `read_receipts.json` and `permissions.json` added to both MCP and dashboard reset cleanup
|
|
16
|
+
- Dashboard workspace API regex aligned with server (`[a-zA-Z0-9_-]`)
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- `toolWaitForReply` missing `markAsRead` calls (read receipts not recorded)
|
|
20
|
+
- `toolBroadcast` bypassing permission checks entirely
|
|
21
|
+
- `toolReset` not cleaning up `permissions.json` and `read_receipts.json`
|
|
22
|
+
|
|
3
23
|
## [3.4.0] - 2026-03-15
|
|
4
24
|
|
|
5
25
|
### 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.1
|
|
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.1</span>
|
|
2825
2846
|
</div>
|
|
2826
2847
|
<div class="profile-popup" id="profile-popup" onclick="event.stopPropagation()">
|
|
2827
2848
|
<div class="profile-popup-header">
|
|
@@ -3448,6 +3469,7 @@ function renderMessages(messages) {
|
|
|
3448
3469
|
'</div>' +
|
|
3449
3470
|
'<div class="msg-content">' + renderMarkdown(m.content) + '</div>' +
|
|
3450
3471
|
buildReactionsHtml(m.id) +
|
|
3472
|
+
buildReadReceipts(m.id) +
|
|
3451
3473
|
'</div></div>';
|
|
3452
3474
|
lastFrom = m.from;
|
|
3453
3475
|
lastTo = m.to;
|
|
@@ -3469,6 +3491,7 @@ function renderMessages(messages) {
|
|
|
3469
3491
|
'<div class="file-meta"><span class="file-icon">📄</span>' + escapeHtml(fileName) + '<span class="file-size">' + fileSize + '</span></div>' +
|
|
3470
3492
|
'<div class="msg-content">' + renderMarkdown(m.content) + '</div>' +
|
|
3471
3493
|
buildReactionsHtml(m.id) +
|
|
3494
|
+
buildReadReceipts(m.id) +
|
|
3472
3495
|
'</div></div>';
|
|
3473
3496
|
lastFrom = m.from;
|
|
3474
3497
|
lastTo = m.to;
|
|
@@ -3486,6 +3509,7 @@ function renderMessages(messages) {
|
|
|
3486
3509
|
'</div>' +
|
|
3487
3510
|
'<div class="msg-content">' + renderMarkdown(m.content) + '</div>' +
|
|
3488
3511
|
buildReactionsHtml(m.id) +
|
|
3512
|
+
buildReadReceipts(m.id) +
|
|
3489
3513
|
'</div></div>';
|
|
3490
3514
|
lastFrom = m.from;
|
|
3491
3515
|
lastTo = m.to;
|
|
@@ -3582,6 +3606,33 @@ function onSearch() {
|
|
|
3582
3606
|
renderMessages(cachedHistory);
|
|
3583
3607
|
}
|
|
3584
3608
|
|
|
3609
|
+
// ==================== READ RECEIPTS ====================
|
|
3610
|
+
|
|
3611
|
+
var cachedReadReceipts = {};
|
|
3612
|
+
|
|
3613
|
+
function fetchReadReceipts() {
|
|
3614
|
+
var pq = activeProject ? '?project=' + encodeURIComponent(activeProject) : '';
|
|
3615
|
+
fetch('/api/read-receipts' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
3616
|
+
cachedReadReceipts = data || {};
|
|
3617
|
+
}).catch(function() {});
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
function buildReadReceipts(msgId) {
|
|
3621
|
+
var receipts = cachedReadReceipts[msgId];
|
|
3622
|
+
if (!receipts) return '';
|
|
3623
|
+
var agents = Object.keys(receipts);
|
|
3624
|
+
if (!agents.length) return '';
|
|
3625
|
+
var html = '<div class="read-receipts">';
|
|
3626
|
+
for (var i = 0; i < agents.length; i++) {
|
|
3627
|
+
var agent = agents[i];
|
|
3628
|
+
var time = receipts[agent] ? new Date(receipts[agent]).toLocaleTimeString() : '';
|
|
3629
|
+
var color = getColor(agent);
|
|
3630
|
+
html += '<div class="read-receipt-dot" style="background:' + color + '" title="Read by ' + escapeHtml(agent) + (time ? ' at ' + time : '') + '">' + initial(agent) + '</div>';
|
|
3631
|
+
}
|
|
3632
|
+
html += '</div>';
|
|
3633
|
+
return html;
|
|
3634
|
+
}
|
|
3635
|
+
|
|
3585
3636
|
// ==================== REACTIONS ====================
|
|
3586
3637
|
|
|
3587
3638
|
var REACTION_EMOJIS = ['\ud83d\udc4d', '\u2705', '\u2764\ufe0f', '\ud83e\udd14', '\ud83d\udd25'];
|
|
@@ -4774,6 +4825,7 @@ function poll() {
|
|
|
4774
4825
|
if (activeView === 'workspaces') fetchWorkspaces();
|
|
4775
4826
|
if (activeView === 'workflows') fetchWorkflows();
|
|
4776
4827
|
if (activeView === 'stats') fetchStats();
|
|
4828
|
+
fetchReadReceipts();
|
|
4777
4829
|
}).catch(function(e) {
|
|
4778
4830
|
console.error('Poll failed:', e);
|
|
4779
4831
|
document.getElementById('conn-detail').textContent = ' ERR: ' + e.message;
|
|
@@ -4918,7 +4970,7 @@ function discoverProjects() {
|
|
|
4918
4970
|
resultsEl.style.display = 'block';
|
|
4919
4971
|
resultsEl.innerHTML = '<div style="font-size:11px;color:var(--text-muted);padding:4px;">Scanning...</div>';
|
|
4920
4972
|
|
|
4921
|
-
fetch('/api/discover').then(function(r) { return r.json(); }).then(function(found) {
|
|
4973
|
+
fetch('/api/discover', { method: 'POST' }).then(function(r) { return r.json(); }).then(function(found) {
|
|
4922
4974
|
if (!found.length) {
|
|
4923
4975
|
resultsEl.innerHTML = '<div style="font-size:11px;color:var(--text-muted);padding:4px;">No new projects found (all discovered projects already added)</div>';
|
|
4924
4976
|
setTimeout(function() { resultsEl.style.display = 'none'; }, 3000);
|
package/dashboard.js
CHANGED
|
@@ -5,6 +5,15 @@ 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');
|
|
@@ -299,7 +308,7 @@ function apiStats(query) {
|
|
|
299
308
|
function apiReset(query) {
|
|
300
309
|
const projectPath = query.get('project') || null;
|
|
301
310
|
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'];
|
|
311
|
+
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
312
|
for (const f of fixedFiles) {
|
|
304
313
|
const p = path.join(dataDir, f);
|
|
305
314
|
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
@@ -734,7 +743,7 @@ function apiLaunchAgent(body) {
|
|
|
734
743
|
}
|
|
735
744
|
|
|
736
745
|
// --- v3.4: Message Edit ---
|
|
737
|
-
function apiEditMessage(body, query) {
|
|
746
|
+
async function apiEditMessage(body, query) {
|
|
738
747
|
const projectPath = query.get('project') || null;
|
|
739
748
|
const { id, content } = body;
|
|
740
749
|
if (!id || !content) return { error: 'Missing "id" and/or "content" fields' };
|
|
@@ -747,36 +756,17 @@ function apiEditMessage(body, query) {
|
|
|
747
756
|
let found = false;
|
|
748
757
|
const now = new Date().toISOString();
|
|
749
758
|
|
|
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');
|
|
759
|
+
// Update in history.jsonl (locked)
|
|
760
|
+
await withFileLock(historyFile, () => {
|
|
761
|
+
if (fs.existsSync(historyFile)) {
|
|
762
|
+
const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n').filter(Boolean);
|
|
776
763
|
const updated = lines.map(line => {
|
|
777
764
|
try {
|
|
778
765
|
const msg = JSON.parse(line);
|
|
779
766
|
if (msg.id === id) {
|
|
767
|
+
found = true;
|
|
768
|
+
if (!msg.edit_history) msg.edit_history = [];
|
|
769
|
+
msg.edit_history.push({ content: msg.content, edited_at: now });
|
|
780
770
|
msg.content = content;
|
|
781
771
|
msg.edited = true;
|
|
782
772
|
msg.edited_at = now;
|
|
@@ -785,8 +775,33 @@ function apiEditMessage(body, query) {
|
|
|
785
775
|
return line;
|
|
786
776
|
} catch { return line; }
|
|
787
777
|
});
|
|
788
|
-
fs.writeFileSync(
|
|
778
|
+
if (found) fs.writeFileSync(historyFile, updated.join('\n') + '\n');
|
|
789
779
|
}
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// Also update in messages.jsonl (locked independently)
|
|
783
|
+
if (found) {
|
|
784
|
+
await withFileLock(messagesFile, () => {
|
|
785
|
+
if (fs.existsSync(messagesFile)) {
|
|
786
|
+
const raw = fs.readFileSync(messagesFile, 'utf8').trim();
|
|
787
|
+
if (raw) {
|
|
788
|
+
const lines = raw.split('\n');
|
|
789
|
+
const updated = lines.map(line => {
|
|
790
|
+
try {
|
|
791
|
+
const msg = JSON.parse(line);
|
|
792
|
+
if (msg.id === id) {
|
|
793
|
+
msg.content = content;
|
|
794
|
+
msg.edited = true;
|
|
795
|
+
msg.edited_at = now;
|
|
796
|
+
return JSON.stringify(msg);
|
|
797
|
+
}
|
|
798
|
+
return line;
|
|
799
|
+
} catch { return line; }
|
|
800
|
+
});
|
|
801
|
+
fs.writeFileSync(messagesFile, updated.join('\n') + '\n');
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
});
|
|
790
805
|
}
|
|
791
806
|
|
|
792
807
|
if (!found) return { error: 'Message not found' };
|
|
@@ -794,7 +809,7 @@ function apiEditMessage(body, query) {
|
|
|
794
809
|
}
|
|
795
810
|
|
|
796
811
|
// --- v3.4: Message Delete ---
|
|
797
|
-
function apiDeleteMessage(body, query) {
|
|
812
|
+
async function apiDeleteMessage(body, query) {
|
|
798
813
|
const projectPath = query.get('project') || null;
|
|
799
814
|
const { id } = body;
|
|
800
815
|
if (!id) return { error: 'Missing "id" field' };
|
|
@@ -806,16 +821,28 @@ function apiDeleteMessage(body, query) {
|
|
|
806
821
|
let found = false;
|
|
807
822
|
let msgFrom = null;
|
|
808
823
|
|
|
809
|
-
// Find the message
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
824
|
+
// Find the message and remove from history.jsonl (locked)
|
|
825
|
+
await withFileLock(historyFile, () => {
|
|
826
|
+
if (fs.existsSync(historyFile)) {
|
|
827
|
+
const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n');
|
|
828
|
+
for (const line of lines) {
|
|
829
|
+
try {
|
|
830
|
+
const msg = JSON.parse(line);
|
|
831
|
+
if (msg.id === id) { found = true; msgFrom = msg.from; break; }
|
|
832
|
+
} catch {}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (found) {
|
|
836
|
+
const allowed = ['Dashboard', 'dashboard', 'system', '__system__'];
|
|
837
|
+
if (allowed.includes(msgFrom)) {
|
|
838
|
+
const filtered = lines.filter(line => {
|
|
839
|
+
try { return JSON.parse(line).id !== id; } catch { return true; }
|
|
840
|
+
});
|
|
841
|
+
fs.writeFileSync(historyFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
|
|
842
|
+
}
|
|
843
|
+
}
|
|
817
844
|
}
|
|
818
|
-
}
|
|
845
|
+
});
|
|
819
846
|
|
|
820
847
|
if (!found) return { error: 'Message not found' };
|
|
821
848
|
|
|
@@ -825,23 +852,16 @@ function apiDeleteMessage(body, query) {
|
|
|
825
852
|
return { error: 'Can only delete messages sent from Dashboard or system' };
|
|
826
853
|
}
|
|
827
854
|
|
|
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
|
-
}
|
|
855
|
+
// Remove from messages.jsonl (locked independently)
|
|
856
|
+
await withFileLock(messagesFile, () => {
|
|
857
|
+
if (fs.existsSync(messagesFile)) {
|
|
858
|
+
const lines = fs.readFileSync(messagesFile, 'utf8').trim().split('\n');
|
|
859
|
+
const filtered = lines.filter(line => {
|
|
860
|
+
try { return JSON.parse(line).id !== id; } catch { return true; }
|
|
861
|
+
});
|
|
862
|
+
fs.writeFileSync(messagesFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
|
|
863
|
+
}
|
|
864
|
+
});
|
|
845
865
|
|
|
846
866
|
return { success: true, id };
|
|
847
867
|
}
|
|
@@ -1121,7 +1141,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1121
1141
|
});
|
|
1122
1142
|
res.end(html);
|
|
1123
1143
|
}
|
|
1124
|
-
else if (url.pathname === '/api/discover' && req.method === '
|
|
1144
|
+
else if (url.pathname === '/api/discover' && req.method === 'POST') {
|
|
1125
1145
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1126
1146
|
res.end(JSON.stringify(apiDiscover()));
|
|
1127
1147
|
}
|
|
@@ -1153,7 +1173,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1153
1173
|
else if (url.pathname === '/api/workspaces' && req.method === 'GET') {
|
|
1154
1174
|
const projectPath = url.searchParams.get('project') || null;
|
|
1155
1175
|
const agentParam = url.searchParams.get('agent');
|
|
1156
|
-
if (agentParam && !/^[a-zA-Z0-
|
|
1176
|
+
if (agentParam && !/^[a-zA-Z0-9_-]{1,20}$/.test(agentParam)) {
|
|
1157
1177
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1158
1178
|
res.end(JSON.stringify({ error: 'Invalid agent name' }));
|
|
1159
1179
|
return;
|
|
@@ -1258,14 +1278,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
1258
1278
|
// --- v3.4: Message Edit ---
|
|
1259
1279
|
else if (url.pathname === '/api/message' && req.method === 'PUT') {
|
|
1260
1280
|
const body = await parseBody(req);
|
|
1261
|
-
const result = apiEditMessage(body, url.searchParams);
|
|
1281
|
+
const result = await apiEditMessage(body, url.searchParams);
|
|
1262
1282
|
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1263
1283
|
res.end(JSON.stringify(result));
|
|
1264
1284
|
}
|
|
1265
1285
|
// --- v3.4: Message Delete ---
|
|
1266
1286
|
else if (url.pathname === '/api/message' && req.method === 'DELETE') {
|
|
1267
1287
|
const body = await parseBody(req);
|
|
1268
|
-
const result = apiDeleteMessage(body, url.searchParams);
|
|
1288
|
+
const result = await apiDeleteMessage(body, url.searchParams);
|
|
1269
1289
|
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1270
1290
|
res.end(JSON.stringify(result));
|
|
1271
1291
|
}
|
|
@@ -1292,6 +1312,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1292
1312
|
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1293
1313
|
res.end(JSON.stringify(result));
|
|
1294
1314
|
}
|
|
1315
|
+
// --- v3.4: Read Receipts ---
|
|
1316
|
+
else if (url.pathname === '/api/read-receipts' && req.method === 'GET') {
|
|
1317
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1318
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1319
|
+
res.end(JSON.stringify(readJson(filePath('read_receipts.json', projectPath))));
|
|
1320
|
+
}
|
|
1295
1321
|
// Server info (LAN mode detection for frontend)
|
|
1296
1322
|
else if (url.pathname === '/api/server-info' && req.method === 'GET') {
|
|
1297
1323
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -1367,8 +1393,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1367
1393
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
1368
1394
|
}
|
|
1369
1395
|
} catch (err) {
|
|
1396
|
+
console.error('Server error:', err.message);
|
|
1370
1397
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1371
|
-
res.end(JSON.stringify({ error:
|
|
1398
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
1372
1399
|
}
|
|
1373
1400
|
});
|
|
1374
1401
|
|
|
@@ -1420,7 +1447,7 @@ server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
|
|
|
1420
1447
|
const dataDir = resolveDataDir();
|
|
1421
1448
|
const lanIP = getLanIP();
|
|
1422
1449
|
console.log('');
|
|
1423
|
-
console.log(' Let Them Talk - Agent Bridge Dashboard v3.4.
|
|
1450
|
+
console.log(' Let Them Talk - Agent Bridge Dashboard v3.4.1');
|
|
1424
1451
|
console.log(' ============================================');
|
|
1425
1452
|
console.log(' Dashboard: http://localhost:' + PORT);
|
|
1426
1453
|
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.1 running (' + (27 + loadedPlugins.length) + ' tools)');
|
|
2025
2101
|
}
|
|
2026
2102
|
|
|
2027
2103
|
main().catch(console.error);
|