let-them-talk 3.4.4 → 3.5.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/server.js CHANGED
@@ -27,6 +27,28 @@ let lastReadOffset = 0; // byte offset into messages.jsonl for efficient polling
27
27
  let heartbeatInterval = null; // heartbeat timer reference
28
28
  let messageSeq = 0; // monotonic sequence counter for message ordering
29
29
  let currentBranch = 'main'; // which branch this agent is on
30
+ let lastSentAt = 0; // timestamp of last sent message (for group cooldown)
31
+
32
+ // --- Group conversation mode ---
33
+ const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
34
+
35
+ function getConfig() {
36
+ if (!fs.existsSync(CONFIG_FILE)) return {};
37
+ try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; }
38
+ }
39
+
40
+ function saveConfig(config) {
41
+ ensureDataDir();
42
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
43
+ }
44
+
45
+ function isGroupMode() {
46
+ return getConfig().conversation_mode === 'group';
47
+ }
48
+
49
+ function getGroupCooldown() {
50
+ return getConfig().group_cooldown || 3000; // default 3s
51
+ }
30
52
 
31
53
  // Rate limiting — prevent broadcast storms and message flooding
32
54
  const rateLimitWindow = 60000; // 1 minute window
@@ -86,6 +108,22 @@ function readJsonl(file) {
86
108
  }).filter(Boolean);
87
109
  }
88
110
 
111
+ // File-based lock for agents.json (prevents registration race conditions)
112
+ const AGENTS_LOCK = AGENTS_FILE + '.lock';
113
+ function lockAgentsFile() {
114
+ const maxWait = 5000; const start = Date.now();
115
+ while (Date.now() - start < maxWait) {
116
+ try { fs.writeFileSync(AGENTS_LOCK, String(process.pid), { flag: 'wx' }); return true; }
117
+ catch { /* lock exists, wait */ }
118
+ const wait = Date.now(); while (Date.now() - wait < 50) {} // busy-wait 50ms
119
+ }
120
+ // Force-break stale lock after timeout
121
+ try { fs.unlinkSync(AGENTS_LOCK); } catch {}
122
+ try { fs.writeFileSync(AGENTS_LOCK, String(process.pid), { flag: 'wx' }); return true; } catch {}
123
+ return false;
124
+ }
125
+ function unlockAgentsFile() { try { fs.unlinkSync(AGENTS_LOCK); } catch {} }
126
+
89
127
  function getAgents() {
90
128
  if (!fs.existsSync(AGENTS_FILE)) return {};
91
129
  try {
@@ -108,9 +146,15 @@ function getAcks() {
108
146
  }
109
147
  }
110
148
 
111
- function isPidAlive(pid) {
149
+ function isPidAlive(pid, lastActivity) {
112
150
  try {
113
151
  process.kill(pid, 0);
152
+ // On Windows, PIDs get reused. If the heartbeat stopped (no activity for 30s = 3 missed
153
+ // heartbeats), treat as stale even if PID exists (it's likely a different process now)
154
+ if (lastActivity) {
155
+ const stale = Date.now() - new Date(lastActivity).getTime();
156
+ if (stale > 30000) return false; // 30s = 3 missed heartbeats
157
+ }
114
158
  return true;
115
159
  } catch {
116
160
  return false;
@@ -185,7 +229,7 @@ function buildMessageResponse(msg, consumedIds) {
185
229
 
186
230
  // Count online agents
187
231
  const agents = getAgents();
188
- const agentsOnline = Object.entries(agents).filter(([, info]) => isPidAlive(info.pid)).length;
232
+ const agentsOnline = Object.entries(agents).filter(([, info]) => isPidAlive(info.pid, info.last_activity)).length;
189
233
 
190
234
  // Count total messages for context window management
191
235
  let totalMessages = 0;
@@ -422,30 +466,32 @@ function getHistoryFile(branch) {
422
466
  function toolRegister(name, provider = null) {
423
467
  ensureDataDir();
424
468
  sanitizeName(name);
469
+ lockAgentsFile();
425
470
 
426
- const agents = getAgents();
427
- if (agents[name] && agents[name].pid !== process.pid && isPidAlive(agents[name].pid)) {
428
- return { error: `Agent "${name}" is already registered by a live process. Choose a different name.` };
429
- }
471
+ try {
472
+ const agents = getAgents();
473
+ if (agents[name] && agents[name].pid !== process.pid && isPidAlive(agents[name].pid, agents[name].last_activity)) {
474
+ return { error: `Agent "${name}" is already registered by a live process. Choose a different name.` };
475
+ }
430
476
 
431
- // If name was previously registered by a dead process, verify token to prevent impersonation
432
- if (agents[name] && agents[name].token && !isPidAlive(agents[name].pid)) {
433
- // Dead agent — only allow re-registration from the same process (same token)
434
- if (registeredToken && registeredToken !== agents[name].token) {
435
- return { error: `Agent "${name}" was previously registered by another process. Choose a different name.` };
477
+ // If name was previously registered by a dead process, verify token to prevent impersonation
478
+ if (agents[name] && agents[name].token && !isPidAlive(agents[name].pid, agents[name].last_activity)) {
479
+ // Dead agent — only allow re-registration from the same process (same token)
480
+ if (registeredToken && registeredToken !== agents[name].token) {
481
+ return { error: `Agent "${name}" was previously registered by another process. Choose a different name.` };
482
+ }
436
483
  }
437
- }
438
484
 
439
- // Clean up old registration if re-registering with a different name
440
- if (registeredName && registeredName !== name && agents[registeredName] && agents[registeredName].pid === process.pid) {
441
- delete agents[registeredName];
442
- }
485
+ // Clean up old registration if re-registering with a different name
486
+ if (registeredName && registeredName !== name && agents[registeredName] && agents[registeredName].pid === process.pid) {
487
+ delete agents[registeredName];
488
+ }
443
489
 
444
- const now = new Date().toISOString();
445
- const token = (agents[name] && agents[name].token) || generateToken();
446
- agents[name] = { pid: process.pid, timestamp: now, last_activity: now, provider: provider || 'unknown', branch: currentBranch, token };
447
- saveAgents(agents);
448
- registeredName = name;
490
+ const now = new Date().toISOString();
491
+ const token = (agents[name] && agents[name].token) || generateToken();
492
+ agents[name] = { pid: process.pid, timestamp: now, last_activity: now, provider: provider || 'unknown', branch: currentBranch, token, started_at: now };
493
+ saveAgents(agents);
494
+ registeredName = name;
449
495
  registeredToken = token;
450
496
 
451
497
  // Auto-create profile if not exists
@@ -468,7 +514,10 @@ function toolRegister(name, provider = null) {
468
514
  }, 10000);
469
515
  heartbeatInterval.unref(); // Don't prevent process exit
470
516
 
471
- return { success: true, message: `Registered as Agent ${name} (PID ${process.pid})` };
517
+ return { success: true, message: `Registered as Agent ${name} (PID ${process.pid})` };
518
+ } finally {
519
+ unlockAgentsFile();
520
+ }
472
521
  }
473
522
 
474
523
  // Update last_activity timestamp for this agent
@@ -500,7 +549,7 @@ function toolListAgents() {
500
549
  const profiles = getProfiles();
501
550
  const result = {};
502
551
  for (const [name, info] of Object.entries(agents)) {
503
- const alive = isPidAlive(info.pid);
552
+ const alive = isPidAlive(info.pid, info.last_activity);
504
553
  const lastActivity = info.last_activity || info.timestamp;
505
554
  const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
506
555
  const profile = profiles[name] || {};
@@ -523,7 +572,7 @@ function toolListAgents() {
523
572
  return { agents: result };
524
573
  }
525
574
 
526
- function toolSendMessage(content, to = null, reply_to = null) {
575
+ async function toolSendMessage(content, to = null, reply_to = null) {
527
576
  if (!registeredName) {
528
577
  return { error: 'You must call register() first' };
529
578
  }
@@ -531,6 +580,15 @@ function toolSendMessage(content, to = null, reply_to = null) {
531
580
  const rateErr = checkRateLimit();
532
581
  if (rateErr) return rateErr;
533
582
 
583
+ // Group mode cooldown — prevent agents from responding too fast
584
+ if (isGroupMode()) {
585
+ const cooldown = getGroupCooldown();
586
+ const elapsed = Date.now() - lastSentAt;
587
+ if (elapsed < cooldown) {
588
+ await sleep(cooldown - elapsed);
589
+ }
590
+ }
591
+
534
592
  const agents = getAgents();
535
593
  const otherAgents = Object.keys(agents).filter(n => n !== registeredName);
536
594
 
@@ -564,7 +622,7 @@ function toolSendMessage(content, to = null, reply_to = null) {
564
622
  if (sizeErr) return sizeErr;
565
623
 
566
624
  // Check if recipient is alive — warn if dead
567
- const recipientAlive = isPidAlive(agents[to].pid);
625
+ const recipientAlive = isPidAlive(agents[to].pid, agents[to].last_activity);
568
626
 
569
627
  // Resolve threading
570
628
  let thread_id = null;
@@ -594,6 +652,19 @@ function toolSendMessage(content, to = null, reply_to = null) {
594
652
  fs.appendFileSync(getMessagesFile(currentBranch), JSON.stringify(msg) + '\n');
595
653
  fs.appendFileSync(getHistoryFile(currentBranch), JSON.stringify(msg) + '\n');
596
654
  touchActivity();
655
+ lastSentAt = Date.now();
656
+
657
+ // In group mode, auto-broadcast: also write to all other agents' queues
658
+ // Skip if this message is already a response to a broadcast (prevents cascade)
659
+ if (isGroupMode() && !reply_to && !msg.broadcast) {
660
+ const otherRecipients = Object.keys(getAgents()).filter(n => n !== registeredName && n !== to);
661
+ for (const other of otherRecipients) {
662
+ if (!canSendTo(registeredName, other)) continue; // respect permissions
663
+ const broadcastMsg = { ...msg, id: generateId(), to: other, broadcast: true, original_to: to };
664
+ fs.appendFileSync(getMessagesFile(currentBranch), JSON.stringify(broadcastMsg) + '\n');
665
+ fs.appendFileSync(getHistoryFile(currentBranch), JSON.stringify(broadcastMsg) + '\n');
666
+ }
667
+ }
597
668
 
598
669
  const result = { success: true, messageId: msg.id, from: msg.from, to: msg.to };
599
670
  if (currentBranch !== 'main') result.branch = currentBranch;
@@ -641,6 +712,7 @@ function toolBroadcast(content) {
641
712
  ids.push({ to, messageId: msg.id });
642
713
  }
643
714
  touchActivity();
715
+ lastSentAt = Date.now();
644
716
 
645
717
  const result = { success: true, sent_to: ids, recipient_count: ids.length };
646
718
  if (skipped.length > 0) result.skipped = skipped;
@@ -868,6 +940,97 @@ async function toolListenCodex(from = null) {
868
940
  };
869
941
  }
870
942
 
943
+ // --- Group conversation tools ---
944
+
945
+ function toolSetConversationMode(mode) {
946
+ if (!registeredName) return { error: 'You must call register() first' };
947
+ if (!['group', 'direct'].includes(mode)) return { error: 'Mode must be "group" or "direct"' };
948
+ const config = getConfig();
949
+ config.conversation_mode = mode;
950
+ if (mode === 'group' && !config.group_cooldown) config.group_cooldown = 3000;
951
+ saveConfig(config);
952
+ return {
953
+ success: true,
954
+ mode,
955
+ message: mode === 'group'
956
+ ? 'Group mode enabled. Use listen_group() to receive batched messages. All messages are shared with everyone.'
957
+ : 'Direct mode enabled. Use listen() for point-to-point messaging.'
958
+ };
959
+ }
960
+
961
+ async function toolListenGroup(timeout_seconds = 300) {
962
+ if (!registeredName) return { error: 'You must call register() first' };
963
+ const timeoutMs = Math.min(Math.max(1, timeout_seconds || 300), 3600) * 1000;
964
+
965
+ setListening(true);
966
+
967
+ // Random stagger to prevent all agents from responding simultaneously (1-3s)
968
+ const stagger = 1000 + Math.random() * 2000;
969
+ await new Promise(r => setTimeout(r, stagger));
970
+
971
+ const deadline = Date.now() + timeoutMs;
972
+ const consumed = getConsumedIds(registeredName);
973
+
974
+ while (Date.now() < deadline) {
975
+ // Collect ALL unconsumed messages addressed to us or broadcast
976
+ const messages = readJsonl(getMessagesFile(currentBranch));
977
+ const batch = [];
978
+ for (const msg of messages) {
979
+ if (consumed.has(msg.id)) continue;
980
+ if (msg.to !== registeredName && msg.to !== '__all__') continue;
981
+ // Permission check
982
+ const perms = getPermissions();
983
+ if (perms[registeredName] && perms[registeredName].can_read) {
984
+ const allowed = perms[registeredName].can_read;
985
+ if (allowed !== '*' && Array.isArray(allowed) && !allowed.includes(msg.from) && !msg.system) continue;
986
+ }
987
+ batch.push(msg);
988
+ consumed.add(msg.id);
989
+ markAsRead(registeredName, msg.id);
990
+ }
991
+
992
+ if (batch.length > 0) {
993
+ saveConsumedIds(registeredName, consumed);
994
+ touchActivity();
995
+ setListening(false);
996
+
997
+ // Get recent history for context
998
+ const history = readJsonl(getHistoryFile(currentBranch));
999
+ const recentHistory = history.slice(-20).map(m => ({
1000
+ from: m.from, to: m.to, content: m.content.substring(0, 500),
1001
+ timestamp: m.timestamp, id: m.id,
1002
+ }));
1003
+
1004
+ // Count agents and who hasn't spoken recently
1005
+ const agents = getAgents();
1006
+ const agentNames = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
1007
+ const recentSpeakers = new Set(history.slice(-10).map(m => m.from));
1008
+ const silent = agentNames.filter(n => !recentSpeakers.has(n) && n !== registeredName);
1009
+
1010
+ return {
1011
+ messages: batch.map(m => ({
1012
+ id: m.id, from: m.from, to: m.to, content: m.content,
1013
+ timestamp: m.timestamp,
1014
+ ...(m.reply_to && { reply_to: m.reply_to }),
1015
+ ...(m.thread_id && { thread_id: m.thread_id }),
1016
+ })),
1017
+ message_count: batch.length,
1018
+ context: recentHistory,
1019
+ agents_online: agentNames.length,
1020
+ agents_silent: silent,
1021
+ hint: silent.length > 0
1022
+ ? `${silent.join(', ')} haven't spoken recently. Consider addressing them.`
1023
+ : 'All agents are active in the conversation.',
1024
+ };
1025
+ }
1026
+
1027
+ await adaptiveSleep(0);
1028
+ }
1029
+
1030
+ setListening(false);
1031
+ return { timeout: true, message: 'No messages received within timeout.', messages: [], message_count: 0 };
1032
+ }
1033
+
871
1034
  function toolGetHistory(limit = 50, thread_id = null) {
872
1035
  limit = Math.min(Math.max(1, limit || 50), 500);
873
1036
  let history = readJsonl(getHistoryFile(currentBranch));
@@ -911,6 +1074,11 @@ function toolHandoff(to, context) {
911
1074
  const sizeErr = validateContentSize(context);
912
1075
  if (sizeErr) return sizeErr;
913
1076
 
1077
+ // Permission check
1078
+ if (!canSendTo(registeredName, to)) {
1079
+ return { error: `Permission denied: you are not allowed to hand off to "${to}"` };
1080
+ }
1081
+
914
1082
  const agents = getAgents();
915
1083
  if (!agents[to]) {
916
1084
  return { error: `Agent "${to}" is not registered` };
@@ -1170,7 +1338,7 @@ function toolReset() {
1170
1338
  }
1171
1339
  }
1172
1340
  // Remove profiles, workflows, branches, permissions, read receipts
1173
- for (const f of [PROFILES_FILE, WORKFLOWS_FILE, BRANCHES_FILE, PERMISSIONS_FILE, READ_RECEIPTS_FILE]) {
1341
+ for (const f of [PROFILES_FILE, WORKFLOWS_FILE, BRANCHES_FILE, PERMISSIONS_FILE, READ_RECEIPTS_FILE, CONFIG_FILE]) {
1174
1342
  if (fs.existsSync(f)) fs.unlinkSync(f);
1175
1343
  }
1176
1344
  // Remove workspaces dir
@@ -1354,9 +1522,9 @@ function toolAdvanceWorkflow(workflowId, notes) {
1354
1522
  nextStep.status = 'in_progress';
1355
1523
  nextStep.started_at = new Date().toISOString();
1356
1524
 
1357
- // Auto-handoff to next assignee
1525
+ // Auto-handoff to next assignee (respecting permissions)
1358
1526
  const agents = getAgents();
1359
- if (nextStep.assignee && agents[nextStep.assignee] && nextStep.assignee !== registeredName) {
1527
+ if (nextStep.assignee && agents[nextStep.assignee] && nextStep.assignee !== registeredName && canSendTo(registeredName, nextStep.assignee)) {
1360
1528
  const handoffContent = `[Workflow "${wf.name}"] Step ${nextStep.id} assigned to you: ${nextStep.description}`;
1361
1529
  messageSeq++;
1362
1530
  const msg = { id: generateId(), seq: messageSeq, from: registeredName, to: nextStep.assignee, content: handoffContent, timestamp: new Date().toISOString(), type: 'handoff' };
@@ -1480,7 +1648,7 @@ function toolListBranches() {
1480
1648
  // --- MCP Server setup ---
1481
1649
 
1482
1650
  const server = new Server(
1483
- { name: 'agent-bridge', version: '3.4.4' },
1651
+ { name: 'agent-bridge', version: '3.5.1' },
1484
1652
  { capabilities: { tools: {} } }
1485
1653
  );
1486
1654
 
@@ -1858,6 +2026,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1858
2026
  properties: {},
1859
2027
  },
1860
2028
  },
2029
+ {
2030
+ name: 'set_conversation_mode',
2031
+ description: 'Switch between "group" (free multi-agent chat with auto-broadcast, cooldown, and batched delivery) and "direct" (default point-to-point messaging). Group mode lets all agents see all messages and collaborate freely.',
2032
+ inputSchema: {
2033
+ type: 'object',
2034
+ properties: {
2035
+ mode: { type: 'string', description: '"group" for free multi-agent chat, "direct" for point-to-point (default)', enum: ['group', 'direct'] },
2036
+ },
2037
+ required: ['mode'],
2038
+ },
2039
+ },
2040
+ {
2041
+ name: 'listen_group',
2042
+ description: 'Listen for messages in group conversation mode. Returns ALL unconsumed messages as a batch (not just one), plus recent conversation context and hints about which agents are silent. Includes a random stagger delay (1-3s) to prevent all agents from responding simultaneously. Use this instead of listen() when in group mode.',
2043
+ inputSchema: {
2044
+ type: 'object',
2045
+ properties: {
2046
+ timeout_seconds: { type: 'number', description: 'Max seconds to wait for messages (default 300)' },
2047
+ },
2048
+ },
2049
+ },
1861
2050
  ],
1862
2051
  };
1863
2052
  });
@@ -1876,7 +2065,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1876
2065
  result = toolListAgents();
1877
2066
  break;
1878
2067
  case 'send_message':
1879
- result = toolSendMessage(args.content, args?.to, args?.reply_to);
2068
+ result = await toolSendMessage(args.content, args?.to, args?.reply_to);
1880
2069
  break;
1881
2070
  case 'wait_for_reply':
1882
2071
  result = await toolWaitForReply(args?.timeout_seconds, args?.from);
@@ -1950,6 +2139,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1950
2139
  case 'list_branches':
1951
2140
  result = toolListBranches();
1952
2141
  break;
2142
+ case 'set_conversation_mode':
2143
+ result = toolSetConversationMode(args.mode);
2144
+ break;
2145
+ case 'listen_group':
2146
+ result = await toolListenGroup(args?.timeout_seconds);
2147
+ break;
1953
2148
  default:
1954
2149
  return {
1955
2150
  content: [{ type: 'text', text: `Unknown tool: ${name}` }],
@@ -1977,6 +2172,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1977
2172
 
1978
2173
  // Clean up agent registration on exit for instant status updates
1979
2174
  process.on('exit', () => {
2175
+ unlockAgentsFile(); // Clean up any held lock
1980
2176
  if (registeredName) {
1981
2177
  try {
1982
2178
  const agents = getAgents();
@@ -1994,7 +2190,7 @@ async function main() {
1994
2190
  ensureDataDir();
1995
2191
  const transport = new StdioServerTransport();
1996
2192
  await server.connect(transport);
1997
- console.error('Agent Bridge MCP server v3.4.4 running (27 tools)');
2193
+ console.error('Agent Bridge MCP server v3.5.1 running (29 tools)');
1998
2194
  }
1999
2195
 
2000
2196
  main().catch(console.error);