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 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.0
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
@@ -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.0
11
+ Let Them Talk — Agent Bridge v3.4.1
12
12
  MCP message broker for inter-agent communication
13
13
  Supports: Claude Code, Gemini CLI, Codex CLI
14
14
 
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.0</span>
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">&#x1f4c4;</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
- if (fs.existsSync(historyFile)) {
752
- const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n').filter(Boolean);
753
- const updated = lines.map(line => {
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(messagesFile, updated.join('\n') + '\n');
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 first to check permissions
810
- if (fs.existsSync(historyFile)) {
811
- const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n');
812
- for (const line of lines) {
813
- try {
814
- const msg = JSON.parse(line);
815
- if (msg.id === id) { found = true; msgFrom = msg.from; break; }
816
- } catch {}
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 history.jsonl
829
- if (fs.existsSync(historyFile)) {
830
- const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n');
831
- const filtered = lines.filter(line => {
832
- try { return JSON.parse(line).id !== id; } catch { return true; }
833
- });
834
- fs.writeFileSync(historyFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
835
- }
836
-
837
- // Remove from messages.jsonl
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 === 'GET') {
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-9]{1,20}$/.test(agentParam)) {
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: err.message }));
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.0');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "let-them-talk",
3
- "version": "3.4.0",
3
+ "version": "3.4.1",
4
4
  "description": "MCP message broker + web dashboard for inter-agent communication. Let AI CLI agents talk to each other.",
5
5
  "main": "server.js",
6
6
  "bin": {
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
- return { success: true, sent_to: ids, recipient_count: otherAgents.length };
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.0 running (' + (27 + loadedPlugins.length) + ' tools)');
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);