let-them-talk 3.3.3 → 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/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');
@@ -228,10 +237,78 @@ function apiStatus(query) {
228
237
  };
229
238
  }
230
239
 
240
+ function apiStats(query) {
241
+ const projectPath = query.get('project') || null;
242
+ const history = readJsonl(filePath('history.jsonl', projectPath));
243
+ const agents = readJson(filePath('agents.json', projectPath));
244
+
245
+ // Per-agent stats
246
+ const perAgent = {};
247
+ let totalMessages = history.length;
248
+ const hourBuckets = new Array(24).fill(0);
249
+
250
+ for (let i = 0; i < history.length; i++) {
251
+ const m = history[i];
252
+ const from = m.from || 'unknown';
253
+ if (!perAgent[from]) {
254
+ perAgent[from] = { messages: 0, responseTimes: [], hours: new Array(24).fill(0) };
255
+ }
256
+ perAgent[from].messages++;
257
+ const ts = new Date(m.timestamp);
258
+ const hour = ts.getHours();
259
+ perAgent[from].hours[hour]++;
260
+ hourBuckets[hour]++;
261
+
262
+ // Compute response time if this is a reply
263
+ if (m.reply_to) {
264
+ for (let j = i - 1; j >= Math.max(0, i - 50); j--) {
265
+ if (history[j].id === m.reply_to) {
266
+ const delta = ts.getTime() - new Date(history[j].timestamp).getTime();
267
+ if (delta > 0 && delta < 3600000) perAgent[from].responseTimes.push(delta);
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ // Build per-agent summary
275
+ const agentStats = {};
276
+ let busiestAgent = null;
277
+ let busiestCount = 0;
278
+ for (const [name, data] of Object.entries(perAgent)) {
279
+ const avgResponseMs = data.responseTimes.length
280
+ ? Math.round(data.responseTimes.reduce((a, b) => a + b, 0) / data.responseTimes.length)
281
+ : null;
282
+ const peakHour = data.hours.indexOf(Math.max(...data.hours));
283
+ agentStats[name] = {
284
+ messages: data.messages,
285
+ avg_response_ms: avgResponseMs,
286
+ peak_hour: peakHour,
287
+ };
288
+ if (data.messages > busiestCount) {
289
+ busiestCount = data.messages;
290
+ busiestAgent = name;
291
+ }
292
+ }
293
+
294
+ // Conversation velocity (messages per minute over last 10 minutes)
295
+ const tenMinAgo = Date.now() - 600000;
296
+ const recentCount = history.filter(m => new Date(m.timestamp).getTime() > tenMinAgo).length;
297
+ const velocity = Math.round((recentCount / 10) * 10) / 10;
298
+
299
+ return {
300
+ total_messages: totalMessages,
301
+ busiest_agent: busiestAgent,
302
+ velocity_per_min: velocity,
303
+ hour_distribution: hourBuckets,
304
+ agents: agentStats,
305
+ };
306
+ }
307
+
231
308
  function apiReset(query) {
232
309
  const projectPath = query.get('project') || null;
233
310
  const dataDir = resolveDataDir(projectPath);
234
- 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'];
235
312
  for (const f of fixedFiles) {
236
313
  const p = path.join(dataDir, f);
237
314
  if (fs.existsSync(p)) fs.unlinkSync(p);
@@ -528,9 +605,12 @@ function apiUpdateTask(body, query) {
528
605
  const task = tasks.find(t => t.id === body.task_id);
529
606
  if (!task) return { error: 'Task not found' };
530
607
 
608
+ const validStatuses = ['pending', 'in_progress', 'done', 'blocked'];
609
+ if (!validStatuses.includes(body.status)) return { error: 'Invalid status. Must be: ' + validStatuses.join(', ') };
531
610
  task.status = body.status;
532
611
  task.updated_at = new Date().toISOString();
533
612
  if (body.notes) {
613
+ if (!Array.isArray(task.notes)) task.notes = [];
534
614
  task.notes.push({ by: 'Dashboard', text: body.notes, at: new Date().toISOString() });
535
615
  }
536
616
 
@@ -662,6 +742,244 @@ function apiLaunchAgent(body) {
662
742
  };
663
743
  }
664
744
 
745
+ // --- v3.4: Message Edit ---
746
+ async function apiEditMessage(body, query) {
747
+ const projectPath = query.get('project') || null;
748
+ const { id, content } = body;
749
+ if (!id || !content) return { error: 'Missing "id" and/or "content" fields' };
750
+ if (content.length > 50000) return { error: 'Content too long (max 50000 chars)' };
751
+
752
+ const dataDir = resolveDataDir(projectPath);
753
+ const historyFile = path.join(dataDir, 'history.jsonl');
754
+ const messagesFile = path.join(dataDir, 'messages.jsonl');
755
+
756
+ let found = false;
757
+ const now = new Date().toISOString();
758
+
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);
763
+ const updated = lines.map(line => {
764
+ try {
765
+ const msg = JSON.parse(line);
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 });
770
+ msg.content = content;
771
+ msg.edited = true;
772
+ msg.edited_at = now;
773
+ return JSON.stringify(msg);
774
+ }
775
+ return line;
776
+ } catch { return line; }
777
+ });
778
+ if (found) fs.writeFileSync(historyFile, updated.join('\n') + '\n');
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
+ });
805
+ }
806
+
807
+ if (!found) return { error: 'Message not found' };
808
+ return { success: true, id, edited_at: now };
809
+ }
810
+
811
+ // --- v3.4: Message Delete ---
812
+ async function apiDeleteMessage(body, query) {
813
+ const projectPath = query.get('project') || null;
814
+ const { id } = body;
815
+ if (!id) return { error: 'Missing "id" field' };
816
+
817
+ const dataDir = resolveDataDir(projectPath);
818
+ const historyFile = path.join(dataDir, 'history.jsonl');
819
+ const messagesFile = path.join(dataDir, 'messages.jsonl');
820
+
821
+ let found = false;
822
+ let msgFrom = null;
823
+
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
+ }
844
+ }
845
+ });
846
+
847
+ if (!found) return { error: 'Message not found' };
848
+
849
+ // Only allow deleting dashboard-injected or system messages
850
+ const allowed = ['Dashboard', 'dashboard', 'system', '__system__'];
851
+ if (!allowed.includes(msgFrom)) {
852
+ return { error: 'Can only delete messages sent from Dashboard or system' };
853
+ }
854
+
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
+ });
865
+
866
+ return { success: true, id };
867
+ }
868
+
869
+ // --- v3.4: Conversation Templates ---
870
+ function apiGetConversationTemplates() {
871
+ const templatesDir = path.join(__dirname, 'conversation-templates');
872
+ if (!fs.existsSync(templatesDir)) {
873
+ // Return built-in templates
874
+ return getBuiltInConversationTemplates();
875
+ }
876
+ const custom = fs.readdirSync(templatesDir)
877
+ .filter(f => f.endsWith('.json'))
878
+ .map(f => { try { return JSON.parse(fs.readFileSync(path.join(templatesDir, f), 'utf8')); } catch { return null; } })
879
+ .filter(Boolean);
880
+ return [...getBuiltInConversationTemplates(), ...custom];
881
+ }
882
+
883
+ function getBuiltInConversationTemplates() {
884
+ return [
885
+ {
886
+ id: 'code-review',
887
+ name: 'Code Review Pipeline',
888
+ description: 'Developer writes code, Reviewer checks it, Tester validates',
889
+ agents: [
890
+ { name: 'Developer', role: 'Developer', prompt: 'You are a developer. Write code as instructed. After completing, send your code to Reviewer for review.' },
891
+ { name: 'Reviewer', role: 'Code Reviewer', prompt: 'You are a code reviewer. Wait for code from Developer. Review it for bugs, style, and best practices. Send feedback back to Developer or approve and forward to Tester.' },
892
+ { name: 'Tester', role: 'QA Tester', prompt: 'You are a QA tester. Wait for approved code from Reviewer. Write and run tests. Report results back to the team.' }
893
+ ],
894
+ workflow: { name: 'Code Review', steps: ['Write Code', 'Review', 'Test', 'Approve'] }
895
+ },
896
+ {
897
+ id: 'debug-squad',
898
+ name: 'Debug Squad',
899
+ description: 'Investigator finds the bug, Fixer patches it, Verifier confirms the fix',
900
+ agents: [
901
+ { name: 'Investigator', role: 'Bug Investigator', prompt: 'You investigate bugs. Analyze error logs, trace code paths, and identify root causes. Send findings to Fixer.' },
902
+ { name: 'Fixer', role: 'Bug Fixer', prompt: 'You fix bugs. Wait for findings from Investigator. Implement fixes and send to Verifier for confirmation.' },
903
+ { name: 'Verifier', role: 'Fix Verifier', prompt: 'You verify bug fixes. Wait for patches from Fixer. Test the fix and confirm resolution or send back for more work.' }
904
+ ],
905
+ workflow: { name: 'Bug Fix', steps: ['Investigate', 'Fix', 'Verify', 'Close'] }
906
+ },
907
+ {
908
+ id: 'feature-build',
909
+ name: 'Feature Development',
910
+ description: 'Architect designs, Builder implements, Reviewer approves',
911
+ agents: [
912
+ { name: 'Architect', role: 'Software Architect', prompt: 'You are a software architect. Design the feature architecture, define interfaces, and create the implementation plan. Send the plan to Builder.' },
913
+ { name: 'Builder', role: 'Developer', prompt: 'You are a developer. Wait for architecture plans from Architect. Implement the feature following the design. Send completed code to Reviewer.' },
914
+ { name: 'Reviewer', role: 'Senior Reviewer', prompt: 'You are a senior reviewer. Review implementations from Builder against the architecture from Architect. Approve or request changes.' }
915
+ ],
916
+ workflow: { name: 'Feature Dev', steps: ['Design', 'Implement', 'Review', 'Ship'] }
917
+ },
918
+ {
919
+ id: 'research-write',
920
+ name: 'Research & Write',
921
+ description: 'Researcher gathers info, Writer creates content, Editor polishes',
922
+ agents: [
923
+ { name: 'Researcher', role: 'Researcher', prompt: 'You are a researcher. Gather information on the given topic. Organize findings and send a research brief to Writer.' },
924
+ { name: 'Writer', role: 'Writer', prompt: 'You are a writer. Wait for research from Researcher. Write clear, well-structured content based on the findings. Send to Editor.' },
925
+ { name: 'Editor', role: 'Editor', prompt: 'You are an editor. Review and polish content from Writer. Check for clarity, accuracy, and style. Send back final version or request revisions.' }
926
+ ],
927
+ workflow: { name: 'Content Pipeline', steps: ['Research', 'Draft', 'Edit', 'Publish'] }
928
+ }
929
+ ];
930
+ }
931
+
932
+ function apiLaunchConversationTemplate(body, query) {
933
+ const projectPath = query.get('project') || null;
934
+ const { template_id } = body;
935
+ if (!template_id) return { error: 'Missing template_id' };
936
+
937
+ const templates = apiGetConversationTemplates();
938
+ const template = templates.find(t => t.id === template_id);
939
+ if (!template) return { error: 'Template not found: ' + template_id };
940
+
941
+ // Return the template config for the frontend to display launch instructions
942
+ return {
943
+ success: true,
944
+ template,
945
+ instructions: template.agents.map(a => ({
946
+ agent_name: a.name,
947
+ role: a.role,
948
+ prompt: `You are "${a.name}" with role "${a.role}". ${a.prompt}\n\nFirst register yourself with: register(name="${a.name}"), then update_profile(role="${a.role}"). Then enter listen mode.`
949
+ }))
950
+ };
951
+ }
952
+
953
+ // --- v3.4: Agent Permissions ---
954
+ function apiUpdatePermissions(body, query) {
955
+ const projectPath = query.get('project') || null;
956
+ const dataDir = resolveDataDir(projectPath);
957
+ const permFile = path.join(dataDir, 'permissions.json');
958
+
959
+ const { agent, permissions } = body;
960
+ if (!agent || !permissions) return { error: 'Missing "agent" and/or "permissions" fields' };
961
+
962
+ let perms = {};
963
+ if (fs.existsSync(permFile)) {
964
+ try { perms = JSON.parse(fs.readFileSync(permFile, 'utf8')); } catch {}
965
+ }
966
+
967
+ // permissions: { can_read: [agents...] or "*", can_write_to: [agents...] or "*", is_admin: bool }
968
+ const allowed = {};
969
+ if (permissions.can_read !== undefined) allowed.can_read = permissions.can_read;
970
+ if (permissions.can_write_to !== undefined) allowed.can_write_to = permissions.can_write_to;
971
+ if (permissions.is_admin !== undefined) allowed.is_admin = !!permissions.is_admin;
972
+ perms[agent] = {
973
+ ...perms[agent],
974
+ ...allowed,
975
+ updated_at: new Date().toISOString()
976
+ };
977
+
978
+ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
979
+ fs.writeFileSync(permFile, JSON.stringify(perms, null, 2));
980
+ return { success: true, agent, permissions: perms[agent] };
981
+ }
982
+
665
983
  // --- HTTP Server ---
666
984
 
667
985
  // Load HTML at startup (re-read on each request in dev for hot-reload)
@@ -696,7 +1014,7 @@ const server = http.createServer(async (req, res) => {
696
1014
  } else if (reqOrigin === allowedOrigin || reqOrigin === `http://127.0.0.1:${PORT}`) {
697
1015
  res.setHeader('Access-Control-Allow-Origin', reqOrigin);
698
1016
  }
699
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
1017
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
700
1018
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
701
1019
 
702
1020
  if (req.method === 'OPTIONS') {
@@ -706,7 +1024,7 @@ const server = http.createServer(async (req, res) => {
706
1024
  }
707
1025
 
708
1026
  // CSRF + DNS rebinding protection: validate Host and Origin on mutating requests
709
- if (req.method === 'POST' || req.method === 'DELETE') {
1027
+ if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
710
1028
  // Check Host header to block DNS rebinding attacks
711
1029
  const host = (req.headers.host || '').replace(/:\d+$/, '');
712
1030
  const validHosts = ['localhost', '127.0.0.1'];
@@ -775,6 +1093,10 @@ const server = http.createServer(async (req, res) => {
775
1093
  res.writeHead(200, { 'Content-Type': 'application/json' });
776
1094
  res.end(JSON.stringify(apiStatus(url.searchParams)));
777
1095
  }
1096
+ else if (url.pathname === '/api/stats' && req.method === 'GET') {
1097
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1098
+ res.end(JSON.stringify(apiStats(url.searchParams)));
1099
+ }
778
1100
  else if (url.pathname === '/api/reset' && req.method === 'POST') {
779
1101
  res.writeHead(200, { 'Content-Type': 'application/json' });
780
1102
  res.end(JSON.stringify(apiReset(url.searchParams)));
@@ -819,7 +1141,7 @@ const server = http.createServer(async (req, res) => {
819
1141
  });
820
1142
  res.end(html);
821
1143
  }
822
- else if (url.pathname === '/api/discover' && req.method === 'GET') {
1144
+ else if (url.pathname === '/api/discover' && req.method === 'POST') {
823
1145
  res.writeHead(200, { 'Content-Type': 'application/json' });
824
1146
  res.end(JSON.stringify(apiDiscover()));
825
1147
  }
@@ -851,7 +1173,7 @@ const server = http.createServer(async (req, res) => {
851
1173
  else if (url.pathname === '/api/workspaces' && req.method === 'GET') {
852
1174
  const projectPath = url.searchParams.get('project') || null;
853
1175
  const agentParam = url.searchParams.get('agent');
854
- if (agentParam && !/^[a-zA-Z0-9]{1,20}$/.test(agentParam)) {
1176
+ if (agentParam && !/^[a-zA-Z0-9_-]{1,20}$/.test(agentParam)) {
855
1177
  res.writeHead(400, { 'Content-Type': 'application/json' });
856
1178
  res.end(JSON.stringify({ error: 'Invalid agent name' }));
857
1179
  return;
@@ -953,6 +1275,49 @@ const server = http.createServer(async (req, res) => {
953
1275
  res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
954
1276
  res.end(JSON.stringify(result));
955
1277
  }
1278
+ // --- v3.4: Message Edit ---
1279
+ else if (url.pathname === '/api/message' && req.method === 'PUT') {
1280
+ const body = await parseBody(req);
1281
+ const result = await apiEditMessage(body, url.searchParams);
1282
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
1283
+ res.end(JSON.stringify(result));
1284
+ }
1285
+ // --- v3.4: Message Delete ---
1286
+ else if (url.pathname === '/api/message' && req.method === 'DELETE') {
1287
+ const body = await parseBody(req);
1288
+ const result = await apiDeleteMessage(body, url.searchParams);
1289
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
1290
+ res.end(JSON.stringify(result));
1291
+ }
1292
+ // --- v3.4: Conversation Templates ---
1293
+ else if (url.pathname === '/api/conversation-templates' && req.method === 'GET') {
1294
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1295
+ res.end(JSON.stringify(apiGetConversationTemplates()));
1296
+ }
1297
+ else if (url.pathname === '/api/conversation-templates/launch' && req.method === 'POST') {
1298
+ const body = await parseBody(req);
1299
+ const result = apiLaunchConversationTemplate(body, url.searchParams);
1300
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
1301
+ res.end(JSON.stringify(result));
1302
+ }
1303
+ // --- v3.4: Agent Permissions ---
1304
+ else if (url.pathname === '/api/permissions' && req.method === 'GET') {
1305
+ const projectPath = url.searchParams.get('project') || null;
1306
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1307
+ res.end(JSON.stringify(readJson(filePath('permissions.json', projectPath))));
1308
+ }
1309
+ else if (url.pathname === '/api/permissions' && req.method === 'POST') {
1310
+ const body = await parseBody(req);
1311
+ const result = apiUpdatePermissions(body, url.searchParams);
1312
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
1313
+ res.end(JSON.stringify(result));
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
+ }
956
1321
  // Server info (LAN mode detection for frontend)
957
1322
  else if (url.pathname === '/api/server-info' && req.method === 'GET') {
958
1323
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1028,8 +1393,9 @@ const server = http.createServer(async (req, res) => {
1028
1393
  res.end(JSON.stringify({ error: 'Not found' }));
1029
1394
  }
1030
1395
  } catch (err) {
1396
+ console.error('Server error:', err.message);
1031
1397
  res.writeHead(500, { 'Content-Type': 'application/json' });
1032
- res.end(JSON.stringify({ error: err.message }));
1398
+ res.end(JSON.stringify({ error: 'Internal server error' }));
1033
1399
  }
1034
1400
  });
1035
1401
 
@@ -1081,7 +1447,7 @@ server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
1081
1447
  const dataDir = resolveDataDir();
1082
1448
  const lanIP = getLanIP();
1083
1449
  console.log('');
1084
- console.log(' Let Them Talk - Agent Bridge Dashboard v3.3.3');
1450
+ console.log(' Let Them Talk - Agent Bridge Dashboard v3.4.1');
1085
1451
  console.log(' ============================================');
1086
1452
  console.log(' Dashboard: http://localhost:' + PORT);
1087
1453
  if (LAN_MODE && lanIP) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "let-them-talk",
3
- "version": "3.3.3",
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": {
@@ -21,6 +21,7 @@
21
21
  "dashboard.html",
22
22
  "cli.js",
23
23
  "templates/",
24
+ "conversation-templates/",
24
25
  "logo.png",
25
26
  "LICENSE",
26
27
  "SECURITY.md",
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.3.3 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);