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/CHANGELOG.md +68 -0
- package/LICENSE +1 -1
- package/README.md +17 -9
- package/cli.js +103 -2
- package/dashboard.html +693 -125
- package/dashboard.js +373 -7
- package/package.json +2 -1
- package/server.js +80 -4
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 === '
|
|
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-
|
|
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:
|
|
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.
|
|
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
|
+
"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
|
-
|
|
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.
|
|
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);
|