let-them-talk 3.7.0 → 3.9.0

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
@@ -72,7 +72,13 @@ function isGroupMode() {
72
72
  }
73
73
 
74
74
  function getGroupCooldown() {
75
- return getConfig().group_cooldown || 3000; // default 3s
75
+ // Adaptive cooldown: scales with online agent count — max(500, N * 500)
76
+ // 2 agents = 1s, 3 = 1.5s, 4 = 2s, 6 = 3s, 10 = 5s
77
+ const configured = getConfig().group_cooldown;
78
+ if (configured) return configured; // respect explicit config
79
+ const agents = getAgents();
80
+ const aliveCount = Object.values(agents).filter(a => isPidAlive(a.pid, a.last_activity)).length;
81
+ return Math.max(500, aliveCount * 500);
76
82
  }
77
83
 
78
84
  // --- Managed conversation mode ---
@@ -364,21 +370,33 @@ function autoCompact() {
364
370
 
365
371
  const messages = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
366
372
 
367
- // Collect ALL consumed IDs across all agents
373
+ // Collect consumed IDs for __group__ messages, only check ALIVE agents
374
+ const agents = getAgents();
375
+ const aliveAgentNames = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
368
376
  const allConsumed = new Set();
377
+ const perAgentConsumed = {};
369
378
  if (fs.existsSync(DATA_DIR)) {
370
379
  for (const f of fs.readdirSync(DATA_DIR)) {
371
380
  if (f.startsWith('consumed-') && f.endsWith('.json')) {
381
+ const agentName = f.replace('consumed-', '').replace('.json', '');
372
382
  try {
373
383
  const ids = JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8'));
384
+ perAgentConsumed[agentName] = new Set(ids);
374
385
  ids.forEach(id => allConsumed.add(id));
375
386
  } catch {}
376
387
  }
377
388
  }
378
389
  }
379
390
 
380
- // Keep only unconsumed messages (for direct messages, only the recipient consumes)
391
+ // Keep messages that are NOT fully consumed
392
+ // For __group__ messages: consumed when ALL ALIVE agents have consumed it (dead agents don't block)
393
+ // For direct messages: consumed when the recipient has consumed it
381
394
  const active = messages.filter(m => {
395
+ if (m.to === '__group__') {
396
+ // __group__: check if all alive agents (except sender) have consumed
397
+ return !aliveAgentNames.every(n => n === m.from || (perAgentConsumed[n] && perAgentConsumed[n].has(m.id)));
398
+ }
399
+ // Direct: standard check
382
400
  if (!allConsumed.has(m.id)) return true;
383
401
  return false;
384
402
  });
@@ -451,7 +469,9 @@ function getUnconsumedMessages(agentName, fromFilter = null) {
451
469
  const consumed = getConsumedIds(agentName);
452
470
  const perms = getPermissions();
453
471
  return messages.filter(m => {
454
- if (m.to !== agentName) return false;
472
+ if (m.to !== agentName && m.to !== '__group__' && m.to !== '__all__') return false;
473
+ // Skip own group messages
474
+ if (m.to === '__group__' && m.from === agentName) return false;
455
475
  if (consumed.has(m.id)) return false;
456
476
  if (fromFilter && m.from !== fromFilter && !m.system) return false;
457
477
  // Permission check: skip messages from senders this agent can't read
@@ -632,12 +652,49 @@ function toolRegister(name, provider = null) {
632
652
  }
633
653
  // Clean up file locks held by dead agents
634
654
  cleanStaleLocks();
655
+ cleanStaleChannelMembers();
635
656
  } catch {}
636
657
  }, 10000);
637
658
  heartbeatInterval.unref(); // Don't prevent process exit
638
659
 
639
660
  // Fire join event + recovery data for returning agents
640
- const result = { success: true, message: `Registered as Agent ${name} (PID ${process.pid})` };
661
+ const config = getConfig();
662
+ const mode = config.conversation_mode || 'direct';
663
+ const otherAgents = Object.keys(getAgents()).filter(n => n !== name);
664
+
665
+ const result = {
666
+ success: true,
667
+ message: `Registered as Agent ${name} (PID ${process.pid})`,
668
+ conversation_mode: mode,
669
+ agents_online: otherAgents,
670
+ guide: {
671
+ critical_rules: [
672
+ 'AFTER EVERY ACTION YOU TAKE, call listen_group() (group/managed mode) or listen() (direct mode) immediately. This is how you receive messages. If you stop listening, you are invisible to the team.',
673
+ 'Never send multiple messages in a row without calling listen_group() between them — you will miss responses.',
674
+ 'Keep messages concise. 2-3 paragraphs max. No essays.',
675
+ 'When you finish a task, report what you did AND what files you changed, then listen again.',
676
+ ],
677
+ first_steps: mode === 'direct'
678
+ ? '1. Call list_agents() to see who is online. 2. Send a message or call listen() to wait for one.'
679
+ : '1. Call get_briefing() for full project context. 2. Call listen_group() to join the conversation. 3. When you receive messages, respond and immediately call listen_group() again.',
680
+ tool_categories: {
681
+ 'MESSAGING (always use these)': 'send_message, broadcast, listen_group (group/managed), listen (direct), check_messages, get_history, get_summary, handoff, share_file',
682
+ 'TEAM COORDINATION': 'get_briefing (project overview), log_decision / get_decisions (prevent re-debating), kb_write / kb_read (shared knowledge), call_vote / cast_vote (team decisions)',
683
+ 'TASK MANAGEMENT': 'create_task, update_task, list_tasks, declare_dependency, check_dependencies, suggest_task (what should I do next?)',
684
+ 'PROGRESS & QUALITY': 'update_progress / get_progress (feature %), request_review / submit_review (code review), get_reputation (leaderboard)',
685
+ 'FILE SAFETY': 'lock_file / unlock_file (prevent conflicts — ALWAYS lock before editing shared files)',
686
+ 'PROFILES & WORKSPACES': 'update_profile, workspace_write / workspace_read (personal storage)',
687
+ 'MANAGED MODE (if active)': 'claim_manager, yield_floor, set_phase — only the manager uses these',
688
+ },
689
+ patterns: {
690
+ 'Starting work': 'get_briefing → check list_tasks → claim a task with update_task(id, "in_progress") → lock_file → do the work → unlock_file → update_task(id, "done") → listen_group',
691
+ 'Sharing knowledge': 'kb_write("api-schema", "POST /auth → {token}") — so others can kb_read it without asking you',
692
+ 'Making decisions': 'log_decision("Use PostgreSQL", "Better JSON support") — so no one re-debates this later',
693
+ 'Disagreements': 'call_vote("Use Redis for caching?", ["yes", "no"]) — let the team decide democratically',
694
+ 'Code review': 'request_review("src/auth.ts", "Check token expiry logic") — another agent will review and approve/request changes',
695
+ },
696
+ },
697
+ };
641
698
 
642
699
  // Recovery: if this agent has prior data, include it
643
700
  const myTasks = getTasks().filter(t => t.assignee === name && t.status !== 'done');
@@ -681,6 +738,10 @@ function setListening(isListening) {
681
738
  const agents = getAgents();
682
739
  if (agents[registeredName]) {
683
740
  agents[registeredName].listening_since = isListening ? new Date().toISOString() : null;
741
+ // Persist last_listened_at so other agents can detect unresponsive agents
742
+ if (isListening) {
743
+ agents[registeredName].last_listened_at = new Date().toISOString();
744
+ }
684
745
  saveAgents(agents);
685
746
  }
686
747
  } catch {}
@@ -703,6 +764,7 @@ function toolListAgents() {
703
764
  status: !alive ? 'dead' : idleSeconds > 60 ? 'sleeping' : 'active',
704
765
  listening_since: info.listening_since || null,
705
766
  is_listening: !!(info.listening_since && alive),
767
+ last_listened_at: info.last_listened_at || null,
706
768
  provider: info.provider || 'unknown',
707
769
  branch: info.branch || 'main',
708
770
  display_name: profile.display_name || name,
@@ -714,7 +776,7 @@ function toolListAgents() {
714
776
  return { agents: result };
715
777
  }
716
778
 
717
- async function toolSendMessage(content, to = null, reply_to = null) {
779
+ async function toolSendMessage(content, to = null, reply_to = null, channel = null) {
718
780
  if (!registeredName) {
719
781
  return { error: 'You must call register() first' };
720
782
  }
@@ -722,9 +784,23 @@ async function toolSendMessage(content, to = null, reply_to = null) {
722
784
  const rateErr = checkRateLimit();
723
785
  if (rateErr) return rateErr;
724
786
 
725
- // Group mode cooldown — prevent agents from responding too fast
787
+ // Group mode cooldown — split by addressing (fast lane / slow lane)
726
788
  if (isGroupMode()) {
727
- const cooldown = getGroupCooldown();
789
+ let cooldown = getGroupCooldown(); // default: adaptive max(500, N*500)
790
+ // Split cooldown: if replying to a message that addressed us, use fast lane (500ms)
791
+ // If not addressed or no reply_to, use slow lane (higher friction)
792
+ if (reply_to) {
793
+ const allMsgs = readJsonl(getMessagesFile(currentBranch));
794
+ const refMsg = allMsgs.find(m => m.id === reply_to);
795
+ if (refMsg && refMsg.addressed_to && refMsg.addressed_to.includes(registeredName)) {
796
+ cooldown = 500; // fast lane: I was addressed
797
+ } else {
798
+ // Slow lane: heavier friction for unaddressed responses
799
+ const agents = getAgents();
800
+ const aliveCount = Object.values(agents).filter(a => isPidAlive(a.pid, a.last_activity)).length;
801
+ cooldown = Math.max(2000, aliveCount * 1000);
802
+ }
803
+ }
728
804
  const elapsed = Date.now() - lastSentAt;
729
805
  if (elapsed < cooldown) {
730
806
  await sleep(cooldown - elapsed);
@@ -827,35 +903,40 @@ async function toolSendMessage(content, to = null, reply_to = null) {
827
903
  }
828
904
 
829
905
  messageSeq++;
906
+ // In group mode: rewrite to → __group__, original to becomes addressed_to
907
+ const isGroup = isGroupMode() && !isManagedMode();
830
908
  const msg = {
831
909
  id: generateId(),
832
910
  seq: messageSeq,
833
911
  from: registeredName,
834
- to,
912
+ to: isGroup ? '__group__' : to,
835
913
  content,
836
914
  timestamp: new Date().toISOString(),
915
+ ...(isGroup && to && { addressed_to: [to] }),
916
+ ...(channel && { channel }),
837
917
  ...(reply_to && { reply_to }),
838
918
  ...(thread_id && { thread_id }),
839
919
  };
840
920
 
921
+ // Validate channel exists (prevents orphan files from typos)
922
+ if (channel && channel !== 'general') {
923
+ const channels = getChannelsData();
924
+ if (!channels[channel]) {
925
+ return { error: `Channel "#${channel}" does not exist. Use join_channel("${channel}") to create it first.` };
926
+ }
927
+ }
928
+
841
929
  ensureDataDir();
842
- fs.appendFileSync(getMessagesFile(currentBranch), JSON.stringify(msg) + '\n');
843
- fs.appendFileSync(getHistoryFile(currentBranch), JSON.stringify(msg) + '\n');
930
+ // Write to channel-specific file if channel specified, otherwise default
931
+ const msgFile = channel ? getChannelMessagesFile(channel) : getMessagesFile(currentBranch);
932
+ const histFile = channel ? getChannelHistoryFile(channel) : getHistoryFile(currentBranch);
933
+ fs.appendFileSync(msgFile, JSON.stringify(msg) + '\n');
934
+ fs.appendFileSync(histFile, JSON.stringify(msg) + '\n');
844
935
  touchActivity();
845
936
  lastSentAt = Date.now();
846
937
 
847
- // In group mode, auto-broadcast: also write to all other agents' queues
848
- // Skip if this message is already a response to a broadcast (prevents cascade)
849
- // NEVER auto-broadcast in managed mode — manager controls communication flow
850
- if (isGroupMode() && !isManagedMode() && !reply_to && !msg.broadcast) {
851
- const otherRecipients = Object.keys(getAgents()).filter(n => n !== registeredName && n !== to);
852
- for (const other of otherRecipients) {
853
- if (!canSendTo(registeredName, other)) continue; // respect permissions
854
- const broadcastMsg = { ...msg, id: generateId(), to: other, broadcast: true, original_to: to };
855
- fs.appendFileSync(getMessagesFile(currentBranch), JSON.stringify(broadcastMsg) + '\n');
856
- fs.appendFileSync(getHistoryFile(currentBranch), JSON.stringify(broadcastMsg) + '\n');
857
- }
858
- }
938
+ // Group mode: O(N) auto-broadcast REMOVED. Messages now use __group__ single-write.
939
+ // The to→__group__ rewrite happens above when the message is created.
859
940
 
860
941
  // Managed mode: auto-advance turns after non-manager sends
861
942
  if (isManagedMode()) {
@@ -906,6 +987,12 @@ async function toolSendMessage(content, to = null, reply_to = null) {
906
987
  result.note = `Agent "${to}" is currently working (not in listen mode). Message queued — they'll see it when they finish their current task and call listen_group().`;
907
988
  }
908
989
 
990
+ // Mode awareness hint: warn if agent seems to be in wrong mode
991
+ const currentMode = getConfig().conversation_mode || 'direct';
992
+ if (currentMode === 'group' || currentMode === 'managed') {
993
+ result.mode_hint = `You're in ${currentMode} mode. Use listen_group() (or listen() — both auto-detect) to stay in the conversation.`;
994
+ }
995
+
909
996
  // Nudge: check if THIS agent has unread messages waiting
910
997
  const myPending = getUnconsumedMessages(registeredName);
911
998
  if (myPending.length > 0) {
@@ -942,6 +1029,32 @@ function toolBroadcast(content) {
942
1029
  }
943
1030
 
944
1031
  ensureDataDir();
1032
+
1033
+ // In group mode: single __group__ write instead of per-agent copies
1034
+ if (isGroupMode() && !isManagedMode()) {
1035
+ messageSeq++;
1036
+ const msg = {
1037
+ id: generateId(),
1038
+ seq: messageSeq,
1039
+ from: registeredName,
1040
+ to: '__group__',
1041
+ content,
1042
+ timestamp: new Date().toISOString(),
1043
+ broadcast: true,
1044
+ };
1045
+ fs.appendFileSync(getMessagesFile(currentBranch), JSON.stringify(msg) + '\n');
1046
+ fs.appendFileSync(getHistoryFile(currentBranch), JSON.stringify(msg) + '\n');
1047
+ touchActivity();
1048
+ lastSentAt = Date.now();
1049
+ const aliveOthers = otherAgents.filter(n => { const a = agents[n]; return isPidAlive(a.pid, a.last_activity); });
1050
+ const result = { success: true, messageId: msg.id, recipient_count: aliveOthers.length, sent_to: aliveOthers.map(n => ({ to: n, messageId: msg.id })) };
1051
+ // Nudge for own unread messages
1052
+ const myPending = getUnconsumedMessages(registeredName);
1053
+ if (myPending.length > 0) { result.you_have_messages = myPending.length; result.urgent = `You have ${myPending.length} unread message(s). Call listen_group() soon.`; }
1054
+ return result;
1055
+ }
1056
+
1057
+ // Direct/managed mode: per-agent writes (original behavior)
945
1058
  const ids = [];
946
1059
  const skipped = [];
947
1060
  for (const to of otherAgents) {
@@ -1089,6 +1202,12 @@ async function toolListen(from = null) {
1089
1202
  return { error: 'You must call register() first' };
1090
1203
  }
1091
1204
 
1205
+ // Auto-detect group/managed mode and delegate to toolListenGroup
1206
+ // This prevents agents from calling the "wrong" listen function
1207
+ if (isGroupMode() || isManagedMode()) {
1208
+ return toolListenGroup();
1209
+ }
1210
+
1092
1211
  setListening(true);
1093
1212
 
1094
1213
  // Check for existing unconsumed messages first
@@ -1232,6 +1351,11 @@ function toolSetConversationMode(mode) {
1232
1351
  }
1233
1352
  saveConfig(config);
1234
1353
 
1354
+ // Notify all agents about mode change (managed mode already broadcasts above)
1355
+ if (mode !== 'managed') {
1356
+ broadcastSystemMessage(`[MODE] Conversation switched to ${mode} mode by ${registeredName}. ${mode === 'group' ? 'All messages are now shared with everyone.' : 'Messages are now point-to-point.'}`, registeredName);
1357
+ }
1358
+
1235
1359
  const messages = {
1236
1360
  group: 'Group mode enabled. Use listen_group() to receive batched messages. All messages are shared with everyone.',
1237
1361
  direct: 'Direct mode enabled. Use listen() for point-to-point messaging.',
@@ -1374,14 +1498,22 @@ function toolSetPhase(phase) {
1374
1498
  };
1375
1499
  }
1376
1500
 
1501
+ // Deterministic stagger delay based on agent name (500-1500ms)
1502
+ // Same agent always gets the same delay, making response ordering predictable
1503
+ function hashStagger(name) {
1504
+ const hash = name.split('').reduce((h, c) => h + c.charCodeAt(0), 0);
1505
+ return 500 + (hash * 137) % 1000; // 0.5-1.5s range
1506
+ }
1507
+
1377
1508
  async function toolListenGroup() {
1378
1509
  if (!registeredName) return { error: 'You must call register() first' };
1379
1510
 
1380
- setListening(true);
1511
+ // Auto-detect direct mode and delegate to toolListen (prevents wrong-function bugs)
1512
+ if (!isGroupMode() && !isManagedMode()) {
1513
+ return toolListen();
1514
+ }
1381
1515
 
1382
- // Random stagger to prevent all agents from responding simultaneously (1-3s)
1383
- const stagger = 1000 + Math.random() * 2000;
1384
- await new Promise(r => setTimeout(r, stagger));
1516
+ setListening(true);
1385
1517
 
1386
1518
  const consumed = getConsumedIds(registeredName);
1387
1519
 
@@ -1390,12 +1522,26 @@ async function toolListenGroup() {
1390
1522
  const chunkDeadline = Date.now() + 300000;
1391
1523
 
1392
1524
  while (Date.now() < chunkDeadline) {
1393
- // Collect ALL unconsumed messages addressed to us or broadcast
1394
- const messages = readJsonl(getMessagesFile(currentBranch));
1525
+ // Collect ALL unconsumed messages from general + all subscribed channels
1526
+ const myChannels = getAgentChannels(registeredName);
1527
+ let messages = readJsonl(getMessagesFile(currentBranch));
1528
+ // Also read from channel-specific files
1529
+ for (const ch of myChannels) {
1530
+ if (ch === 'general') continue; // general uses the main messages file
1531
+ const chFile = getChannelMessagesFile(ch);
1532
+ if (fs.existsSync(chFile)) {
1533
+ const chMsgs = readJsonl(chFile);
1534
+ messages = messages.concat(chMsgs);
1535
+ }
1536
+ }
1537
+ // Sort by timestamp for consistent ordering
1538
+ messages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
1395
1539
  const batch = [];
1396
1540
  for (const msg of messages) {
1397
1541
  if (consumed.has(msg.id)) continue;
1398
- if (msg.to !== registeredName && msg.to !== '__all__') continue;
1542
+ // Skip own messages in group mode (agent already knows what it sent)
1543
+ if (msg.to === '__group__' && msg.from === registeredName) { consumed.add(msg.id); continue; }
1544
+ if (msg.to !== registeredName && msg.to !== '__all__' && msg.to !== '__group__') continue;
1399
1545
  // Permission check
1400
1546
  const perms = getPermissions();
1401
1547
  if (perms[registeredName] && perms[registeredName].can_read) {
@@ -1412,6 +1558,42 @@ async function toolListenGroup() {
1412
1558
  touchActivity();
1413
1559
  setListening(false);
1414
1560
 
1561
+ // Post-receive stagger: deterministic delay based on agent name
1562
+ // Prevents all agents from responding simultaneously to the same batch
1563
+ const staggerMs = hashStagger(registeredName);
1564
+ if (staggerMs > 0) {
1565
+ await new Promise(r => setTimeout(r, staggerMs));
1566
+ }
1567
+
1568
+ // Sort batch by priority: system > threaded replies > direct > broadcast
1569
+ // Within each category, maintain chronological order
1570
+ function messagePriority(m) {
1571
+ if (m.system || m.from === '__system__') return 0;
1572
+ if (m.reply_to || m.thread_id) return 1;
1573
+ if (!m.broadcast) return 2;
1574
+ return 3;
1575
+ }
1576
+ batch.sort((a, b) => {
1577
+ const pa = messagePriority(a), pb = messagePriority(b);
1578
+ if (pa !== pb) return pa - pb;
1579
+ return new Date(a.timestamp) - new Date(b.timestamp);
1580
+ });
1581
+
1582
+ // Build batch summary for triage
1583
+ const summaryCounts = {};
1584
+ for (const m of batch) {
1585
+ const type = m.system || m.from === '__system__' ? 'system'
1586
+ : m.broadcast ? 'broadcast' : (m.reply_to || m.thread_id) ? 'thread' : 'direct';
1587
+ const key = `${m.from}:${type}`;
1588
+ summaryCounts[key] = (summaryCounts[key] || 0) + 1;
1589
+ }
1590
+ const summaryParts = [];
1591
+ for (const [key, count] of Object.entries(summaryCounts)) {
1592
+ const [from, type] = key.split(':');
1593
+ summaryParts.push(`${count} ${type} from ${from}`);
1594
+ }
1595
+ const batchSummary = `${batch.length} messages: ${summaryParts.join(', ')}`;
1596
+
1415
1597
  // Get recent history for context
1416
1598
  const history = readJsonl(getHistoryFile(currentBranch));
1417
1599
  const recentHistory = history.slice(-20).map(m => ({
@@ -1437,14 +1619,33 @@ async function toolListenGroup() {
1437
1619
  ...(ageSec > 30 && { delayed: true }),
1438
1620
  ...(m.reply_to && { reply_to: m.reply_to }),
1439
1621
  ...(m.thread_id && { thread_id: m.thread_id }),
1622
+ // addressed_to hint for group messages
1623
+ ...(m.addressed_to && { addressed_to: m.addressed_to }),
1624
+ ...(m.to === '__group__' && {
1625
+ addressed_to_you: !m.addressed_to || m.addressed_to.includes(registeredName),
1626
+ should_respond: !m.addressed_to || m.addressed_to.includes(registeredName),
1627
+ }),
1440
1628
  };
1441
1629
  }),
1442
1630
  message_count: batch.length,
1631
+ batch_summary: batchSummary,
1443
1632
  context: recentHistory,
1444
1633
  agents_online: agentNames.length,
1445
1634
  agents_silent: silent,
1446
1635
  agents_status: agentNames.reduce(function(acc, n) {
1447
- acc[n] = agents[n].listening_since ? 'listening' : 'working';
1636
+ if (agents[n].listening_since) {
1637
+ acc[n] = 'listening';
1638
+ } else {
1639
+ // Check for unresponsive: not listening, >2min since last listen, has pending messages
1640
+ const lastListened = agents[n].last_listened_at;
1641
+ const sinceLastListen = lastListened ? Date.now() - new Date(lastListened).getTime() : Infinity;
1642
+ const pendingForAgent = getUnconsumedMessages(n);
1643
+ if (sinceLastListen > 120000 && pendingForAgent.length > 0) {
1644
+ acc[n] = 'unresponsive';
1645
+ } else {
1646
+ acc[n] = 'working';
1647
+ }
1648
+ }
1448
1649
  return acc;
1449
1650
  }, {}),
1450
1651
  hint: silent.length > 0
@@ -1735,6 +1936,15 @@ function toolUpdateTask(taskId, status, notes = null) {
1735
1936
  return { error: `Task not found: ${taskId}` };
1736
1937
  }
1737
1938
 
1939
+ // Prevent race condition: can't claim a task already in_progress by another agent
1940
+ if (status === 'in_progress' && task.status === 'in_progress' && task.assignee && task.assignee !== registeredName) {
1941
+ return { error: `Task already claimed by ${task.assignee}. Use suggest_task() to find another task.` };
1942
+ }
1943
+ // Auto-assign on claim
1944
+ if (status === 'in_progress' && !task.assignee) {
1945
+ task.assignee = registeredName;
1946
+ }
1947
+
1738
1948
  task.status = status;
1739
1949
  task.updated_at = new Date().toISOString();
1740
1950
  if (notes) {
@@ -2188,6 +2398,112 @@ function getVotes() { return readJsonFile(VOTES_FILE) || []; }
2188
2398
  function getReviews() { return readJsonFile(REVIEWS_FILE) || []; }
2189
2399
  function getDeps() { return readJsonFile(DEPS_FILE) || []; }
2190
2400
 
2401
+ // --- Channel helpers ---
2402
+ const CHANNELS_FILE_PATH = path.join(DATA_DIR, 'channels.json');
2403
+
2404
+ function getChannelsData() {
2405
+ const data = readJsonFile(CHANNELS_FILE_PATH);
2406
+ if (!data) return { general: { description: 'General channel — all agents', members: ['*'], created_by: 'system', created_at: new Date().toISOString() } };
2407
+ return data;
2408
+ }
2409
+
2410
+ function saveChannelsData(channels) { writeJsonFile(CHANNELS_FILE_PATH, channels); }
2411
+
2412
+ function getChannelMessagesFile(channelName) {
2413
+ if (!channelName || channelName === 'general') return getMessagesFile(currentBranch);
2414
+ return path.join(DATA_DIR, 'channel-' + sanitizeName(channelName) + '-messages.jsonl');
2415
+ }
2416
+
2417
+ function getChannelHistoryFile(channelName) {
2418
+ if (!channelName || channelName === 'general') return getHistoryFile(currentBranch);
2419
+ return path.join(DATA_DIR, 'channel-' + sanitizeName(channelName) + '-history.jsonl');
2420
+ }
2421
+
2422
+ function isChannelMember(channelName, agentName) {
2423
+ const channels = getChannelsData();
2424
+ if (!channels[channelName]) return false;
2425
+ return channels[channelName].members.includes('*') || channels[channelName].members.includes(agentName);
2426
+ }
2427
+
2428
+ function getAgentChannels(agentName) {
2429
+ const channels = getChannelsData();
2430
+ return Object.keys(channels).filter(ch => channels[ch].members.includes('*') || channels[ch].members.includes(agentName));
2431
+ }
2432
+
2433
+ // Cleanup dead agents from channel membership (called from heartbeat)
2434
+ function cleanStaleChannelMembers() {
2435
+ const channels = getChannelsData();
2436
+ const agents = getAgents();
2437
+ let changed = false;
2438
+ for (const [name, ch] of Object.entries(channels)) {
2439
+ if (name === 'general') continue; // general uses '*', no cleanup needed
2440
+ const before = ch.members.length;
2441
+ ch.members = ch.members.filter(m => m === '*' || (agents[m] && isPidAlive(agents[m].pid, agents[m].last_activity)));
2442
+ if (ch.members.length !== before) changed = true;
2443
+ }
2444
+ if (changed) saveChannelsData(channels);
2445
+ }
2446
+
2447
+ function toolJoinChannel(channelName, description) {
2448
+ if (!registeredName) return { error: 'You must call register() first' };
2449
+ if (typeof channelName !== 'string' || channelName.length < 1 || channelName.length > 30) return { error: 'Channel name must be 1-30 chars' };
2450
+ sanitizeName(channelName);
2451
+
2452
+ const channels = getChannelsData();
2453
+ if (!channels[channelName]) {
2454
+ // Create new channel
2455
+ channels[channelName] = {
2456
+ description: (description || '').substring(0, 200),
2457
+ members: [registeredName],
2458
+ created_by: registeredName,
2459
+ created_at: new Date().toISOString(),
2460
+ };
2461
+ } else if (!isChannelMember(channelName, registeredName)) {
2462
+ channels[channelName].members.push(registeredName);
2463
+ } else {
2464
+ return { success: true, channel: channelName, message: 'Already a member of #' + channelName };
2465
+ }
2466
+ saveChannelsData(channels);
2467
+ touchActivity();
2468
+ return { success: true, channel: channelName, members: channels[channelName].members, message: 'Joined #' + channelName };
2469
+ }
2470
+
2471
+ function toolLeaveChannel(channelName) {
2472
+ if (!registeredName) return { error: 'You must call register() first' };
2473
+ if (channelName === 'general') return { error: 'Cannot leave #general' };
2474
+
2475
+ const channels = getChannelsData();
2476
+ if (!channels[channelName]) return { error: 'Channel not found: #' + channelName };
2477
+ channels[channelName].members = channels[channelName].members.filter(m => m !== registeredName);
2478
+ // Auto-delete empty channels (except general)
2479
+ if (channels[channelName].members.length === 0) delete channels[channelName];
2480
+ saveChannelsData(channels);
2481
+ touchActivity();
2482
+ return { success: true, channel: channelName, message: 'Left #' + channelName };
2483
+ }
2484
+
2485
+ function toolListChannels() {
2486
+ const channels = getChannelsData();
2487
+ const result = {};
2488
+ for (const [name, ch] of Object.entries(channels)) {
2489
+ const msgFile = getChannelMessagesFile(name);
2490
+ let msgCount = 0;
2491
+ if (fs.existsSync(msgFile)) {
2492
+ const content = fs.readFileSync(msgFile, 'utf8').trim();
2493
+ if (content) msgCount = content.split('\n').length;
2494
+ }
2495
+ result[name] = {
2496
+ description: ch.description || '',
2497
+ members: ch.members,
2498
+ member_count: ch.members.includes('*') ? 'all' : ch.members.length,
2499
+ created_by: ch.created_by,
2500
+ message_count: msgCount,
2501
+ you_are_member: isChannelMember(name, registeredName),
2502
+ };
2503
+ }
2504
+ return { channels: result, your_channels: getAgentChannels(registeredName) };
2505
+ }
2506
+
2191
2507
  // Auto-cleanup dead agent locks (called from heartbeat)
2192
2508
  function cleanStaleLocks() {
2193
2509
  const locks = getLocks();
@@ -2238,6 +2554,34 @@ function fireEvent(eventName, data) {
2238
2554
  }
2239
2555
  }
2240
2556
 
2557
+ function toolGetGuide() {
2558
+ if (!registeredName) return { error: 'You must call register() first' };
2559
+ const config = getConfig();
2560
+ const mode = config.conversation_mode || 'direct';
2561
+ return {
2562
+ your_name: registeredName,
2563
+ conversation_mode: mode,
2564
+ critical_rules: [
2565
+ 'AFTER EVERY ACTION, call listen_group() (group/managed) or listen() (direct). This is how you receive messages.',
2566
+ 'Never send multiple messages without listening between them.',
2567
+ 'Keep messages concise — 2-3 paragraphs max.',
2568
+ 'When you finish a task, report what you did + files changed, then listen again.',
2569
+ 'ALWAYS lock_file() before editing shared files, unlock_file() when done.',
2570
+ 'Use log_decision() for any team decisions so they are not re-debated.',
2571
+ 'Use kb_write() to share knowledge (API specs, conventions) so others can read without asking.',
2572
+ ],
2573
+ tool_categories: {
2574
+ 'MESSAGING': 'send_message, broadcast, listen_group, listen, check_messages, get_history, get_summary, handoff, share_file',
2575
+ 'COORDINATION': 'get_briefing, log_decision, get_decisions, kb_write, kb_read, kb_list, call_vote, cast_vote, vote_status',
2576
+ 'TASKS': 'create_task, update_task, list_tasks, declare_dependency, check_dependencies, suggest_task',
2577
+ 'QUALITY': 'update_progress, get_progress, request_review, submit_review, get_reputation',
2578
+ 'SAFETY': 'lock_file, unlock_file',
2579
+ 'MANAGED MODE': 'claim_manager, yield_floor, set_phase (manager only)',
2580
+ },
2581
+ workflow: '1. get_briefing → 2. check list_tasks/suggest_task → 3. claim task → 4. lock_file → 5. do work → 6. unlock_file → 7. update_task done → 8. listen_group',
2582
+ };
2583
+ }
2584
+
2241
2585
  function toolGetBriefing() {
2242
2586
  if (!registeredName) return { error: 'You must call register() first' };
2243
2587
 
@@ -2798,7 +3142,7 @@ function toolSuggestTask() {
2798
3142
  // --- MCP Server setup ---
2799
3143
 
2800
3144
  const server = new Server(
2801
- { name: 'agent-bridge', version: '3.7.0' },
3145
+ { name: 'agent-bridge', version: '3.9.0' },
2802
3146
  { capabilities: { tools: {} } }
2803
3147
  );
2804
3148
 
@@ -2807,7 +3151,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2807
3151
  tools: [
2808
3152
  {
2809
3153
  name: 'register',
2810
- description: 'Register this agent\'s identity (any name, e.g. "A", "Coder", "Reviewer"). Must be called before any other tool.',
3154
+ description: 'Register this agent\'s identity. Must be called first. Returns a collaboration guide with all tool categories, critical rules, and workflow patterns READ IT CAREFULLY before doing anything else. Then call get_briefing() for project context, then listen_group() to join the conversation.',
2811
3155
  inputSchema: {
2812
3156
  type: 'object',
2813
3157
  properties: {
@@ -2849,6 +3193,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2849
3193
  type: 'string',
2850
3194
  description: 'ID of a previous message to thread this reply under (optional)',
2851
3195
  },
3196
+ channel: {
3197
+ type: 'string',
3198
+ description: 'Channel to send to (optional — omit for #general). Use join_channel() first to create channels.',
3199
+ },
2852
3200
  },
2853
3201
  required: ['content'],
2854
3202
  },
@@ -2886,7 +3234,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2886
3234
  },
2887
3235
  {
2888
3236
  name: 'listen',
2889
- description: 'Listen for messages indefinitely. Unlike wait_for_reply, this never times out it blocks until a message arrives. The agent should call listen() after finishing any task to stay available. After receiving a message, process it, respond, then call listen() again.',
3237
+ description: 'Listen for messages indefinitely. Auto-detects conversation mode: in group/managed mode, behaves like listen_group() (returns batched messages with agent statuses). In direct mode, returns one message at a time. Either listen() or listen_group() works in any mode they auto-delegate to the correct behavior.',
2890
3238
  inputSchema: {
2891
3239
  type: 'object',
2892
3240
  properties: {
@@ -3209,13 +3557,34 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
3209
3557
  },
3210
3558
  {
3211
3559
  name: 'listen_group',
3212
- description: 'Listen for messages in group or managed conversation mode. Blocks indefinitely until messages arrive never times out. Returns ALL unconsumed messages as a batch, plus conversation context, agent statuses, and hints. After processing messages and responding, call listen_group() again immediately. This is how you stay in the conversation.',
3560
+ description: 'Listen for messages in group or managed conversation mode. Auto-detects mode: in direct mode, behaves like listen(). Returns ALL unconsumed messages as a sorted batch (system > threaded > direct > broadcast), plus batch_summary, agent statuses, and hints. Either listen() or listen_group() works in any mode they auto-delegate. Call again immediately after responding.',
3213
3561
  inputSchema: {
3214
3562
  type: 'object',
3215
3563
  properties: {},
3216
3564
  },
3217
3565
  },
3566
+ // --- Channels ---
3567
+ {
3568
+ name: 'join_channel',
3569
+ description: 'Join or create a channel. Channels let sub-teams communicate without flooding the main conversation. Auto-joined to #general on register. Use channels when team size > 4.',
3570
+ inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Channel name (1-30 chars, e.g. "backend", "testing")' }, description: { type: 'string', description: 'Channel description (optional, max 200 chars)' } }, required: ['name'] },
3571
+ },
3572
+ {
3573
+ name: 'leave_channel',
3574
+ description: 'Leave a channel. You will stop receiving messages from it. Cannot leave #general.',
3575
+ inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Channel to leave' } }, required: ['name'] },
3576
+ },
3577
+ {
3578
+ name: 'list_channels',
3579
+ description: 'List all channels with members, message counts, and your membership status.',
3580
+ inputSchema: { type: 'object', properties: {} },
3581
+ },
3218
3582
  // --- Briefing & Recovery ---
3583
+ {
3584
+ name: 'get_guide',
3585
+ description: 'Get the collaboration guide — all tool categories, critical rules, and workflow patterns. Call this if you are unsure how to use the tools or need a refresher on best practices.',
3586
+ inputSchema: { type: 'object', properties: {} },
3587
+ },
3219
3588
  {
3220
3589
  name: 'get_briefing',
3221
3590
  description: 'Get a full project briefing: who is online, active tasks, recent decisions, knowledge base, locked files, progress, and project files. Call this when joining a project or after being away. One call = fully onboarded.',
@@ -3375,7 +3744,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3375
3744
  result = toolListAgents();
3376
3745
  break;
3377
3746
  case 'send_message':
3378
- result = await toolSendMessage(args.content, args?.to, args?.reply_to);
3747
+ result = await toolSendMessage(args.content, args?.to, args?.reply_to, args?.channel);
3379
3748
  break;
3380
3749
  case 'wait_for_reply':
3381
3750
  result = await toolWaitForReply(args?.timeout_seconds, args?.from);
@@ -3455,6 +3824,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3455
3824
  case 'listen_group':
3456
3825
  result = await toolListenGroup();
3457
3826
  break;
3827
+ case 'join_channel':
3828
+ result = toolJoinChannel(args.name, args?.description);
3829
+ break;
3830
+ case 'leave_channel':
3831
+ result = toolLeaveChannel(args.name);
3832
+ break;
3833
+ case 'list_channels':
3834
+ result = toolListChannels();
3835
+ break;
3836
+ case 'get_guide':
3837
+ result = toolGetGuide();
3838
+ break;
3458
3839
  case 'get_briefing':
3459
3840
  result = toolGetBriefing();
3460
3841
  break;
@@ -3538,14 +3919,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3538
3919
  };
3539
3920
  }
3540
3921
 
3541
- // Global hook: on non-listen tools, check for pending messages and nudge the agent
3922
+ // Global hook: on non-listen tools, check for pending messages and nudge with escalating urgency
3542
3923
  const listenTools = ['listen', 'listen_group', 'listen_codex', 'wait_for_reply', 'check_messages'];
3543
3924
  if (registeredName && !listenTools.includes(name) && (isGroupMode() || isManagedMode())) {
3544
3925
  try {
3545
3926
  const pending = getUnconsumedMessages(registeredName);
3546
3927
  if (pending.length > 0 && !result.you_have_messages) {
3547
3928
  result._pending_messages = pending.length;
3548
- result._nudge = `You have ${pending.length} unread message(s) from the team. Finish your current task quickly, then call listen_group() to read them.`;
3929
+ // Escalate urgency based on oldest pending message age
3930
+ const oldestAge = pending.reduce((max, m) => {
3931
+ const age = Date.now() - new Date(m.timestamp).getTime();
3932
+ return age > max ? age : max;
3933
+ }, 0);
3934
+ const ageSec = Math.round(oldestAge / 1000);
3935
+ if (ageSec > 120) {
3936
+ result._nudge = `CRITICAL: ${pending.length} message(s) waiting ${Math.round(ageSec / 60)}+ min. Team is likely blocked on you. Call listen_group() NOW.`;
3937
+ } else if (ageSec > 30) {
3938
+ result._nudge = `URGENT: ${pending.length} message(s) waiting ${ageSec}s. Team may be blocked. Call listen_group() soon.`;
3939
+ } else {
3940
+ result._nudge = `You have ${pending.length} unread message(s). Call listen_group() after this to read them.`;
3941
+ }
3549
3942
  }
3550
3943
  } catch {}
3551
3944
  }
@@ -3603,7 +3996,7 @@ async function main() {
3603
3996
  ensureDataDir();
3604
3997
  const transport = new StdioServerTransport();
3605
3998
  await server.connect(transport);
3606
- console.error('Agent Bridge MCP server v3.7.0 running (52 tools)');
3999
+ console.error('Agent Bridge MCP server v3.9.0 running (56 tools)');
3607
4000
  }
3608
4001
 
3609
4002
  main().catch(console.error);