let-them-talk 3.8.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.
Files changed (4) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/cli.js +1 -1
  3. package/package.json +1 -1
  4. package/server.js +193 -10
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.9.0] - 2026-03-17
4
+
5
+ ### Added — Channels & Split Cooldown
6
+
7
+ - **`join_channel(name, description?)`** — create or join a channel for sub-team communication
8
+ - **`leave_channel(name)`** — leave a channel (can't leave #general, empty channels auto-delete)
9
+ - **`list_channels()`** — list all channels with members, message counts, membership status
10
+ - **`send_message` channel parameter** — send to specific channel (`channel-{name}-messages.jsonl`)
11
+ - **`listen_group` reads all subscribed channels** — merges messages from general + channel files, sorted by timestamp
12
+ - **Channel validation** — sending to nonexistent channel returns error with hint to create it
13
+ - **Ghost member cleanup** — heartbeat auto-removes dead agents from channel membership
14
+ - **#general auto-created** — `members: ["*"]` (everyone), uses existing messages.jsonl for backward compat
15
+ - **Split cooldown (reply_to-based)** — fast lane (500ms) for addressed agents, slow lane (max 2000, N*1000) for unaddressed, incentivizes threading
16
+
17
+ ### Fixed
18
+ - Task race condition — `update_task` now rejects claiming a task already in_progress by another agent, auto-assigns on claim
19
+
3
20
  ## [3.8.0] - 2026-03-16
4
21
 
5
22
  ### Changed — Group Conversation Overhaul
package/cli.js CHANGED
@@ -9,7 +9,7 @@ const command = process.argv[2];
9
9
 
10
10
  function printUsage() {
11
11
  console.log(`
12
- Let Them Talk — Agent Bridge v3.8.0
12
+ Let Them Talk — Agent Bridge v3.9.0
13
13
  MCP message broker for inter-agent communication
14
14
  Supports: Claude Code, Gemini CLI, Codex CLI, Ollama
15
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "let-them-talk",
3
- "version": "3.8.0",
3
+ "version": "3.9.0",
4
4
  "description": "MCP message broker + web dashboard for inter-agent communication. Let AI CLI agents talk to each other.",
5
5
  "main": "server.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -652,6 +652,7 @@ function toolRegister(name, provider = null) {
652
652
  }
653
653
  // Clean up file locks held by dead agents
654
654
  cleanStaleLocks();
655
+ cleanStaleChannelMembers();
655
656
  } catch {}
656
657
  }, 10000);
657
658
  heartbeatInterval.unref(); // Don't prevent process exit
@@ -775,7 +776,7 @@ function toolListAgents() {
775
776
  return { agents: result };
776
777
  }
777
778
 
778
- async function toolSendMessage(content, to = null, reply_to = null) {
779
+ async function toolSendMessage(content, to = null, reply_to = null, channel = null) {
779
780
  if (!registeredName) {
780
781
  return { error: 'You must call register() first' };
781
782
  }
@@ -783,9 +784,23 @@ async function toolSendMessage(content, to = null, reply_to = null) {
783
784
  const rateErr = checkRateLimit();
784
785
  if (rateErr) return rateErr;
785
786
 
786
- // Group mode cooldown — prevent agents from responding too fast
787
+ // Group mode cooldown — split by addressing (fast lane / slow lane)
787
788
  if (isGroupMode()) {
788
- 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
+ }
789
804
  const elapsed = Date.now() - lastSentAt;
790
805
  if (elapsed < cooldown) {
791
806
  await sleep(cooldown - elapsed);
@@ -898,13 +913,25 @@ async function toolSendMessage(content, to = null, reply_to = null) {
898
913
  content,
899
914
  timestamp: new Date().toISOString(),
900
915
  ...(isGroup && to && { addressed_to: [to] }),
916
+ ...(channel && { channel }),
901
917
  ...(reply_to && { reply_to }),
902
918
  ...(thread_id && { thread_id }),
903
919
  };
904
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
+
905
929
  ensureDataDir();
906
- fs.appendFileSync(getMessagesFile(currentBranch), JSON.stringify(msg) + '\n');
907
- 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');
908
935
  touchActivity();
909
936
  lastSentAt = Date.now();
910
937
 
@@ -1495,8 +1522,20 @@ async function toolListenGroup() {
1495
1522
  const chunkDeadline = Date.now() + 300000;
1496
1523
 
1497
1524
  while (Date.now() < chunkDeadline) {
1498
- // Collect ALL unconsumed messages: direct to us, __group__ (everyone), __all__, or system
1499
- 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));
1500
1539
  const batch = [];
1501
1540
  for (const msg of messages) {
1502
1541
  if (consumed.has(msg.id)) continue;
@@ -1897,6 +1936,15 @@ function toolUpdateTask(taskId, status, notes = null) {
1897
1936
  return { error: `Task not found: ${taskId}` };
1898
1937
  }
1899
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
+
1900
1948
  task.status = status;
1901
1949
  task.updated_at = new Date().toISOString();
1902
1950
  if (notes) {
@@ -2350,6 +2398,112 @@ function getVotes() { return readJsonFile(VOTES_FILE) || []; }
2350
2398
  function getReviews() { return readJsonFile(REVIEWS_FILE) || []; }
2351
2399
  function getDeps() { return readJsonFile(DEPS_FILE) || []; }
2352
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
+
2353
2507
  // Auto-cleanup dead agent locks (called from heartbeat)
2354
2508
  function cleanStaleLocks() {
2355
2509
  const locks = getLocks();
@@ -2988,7 +3142,7 @@ function toolSuggestTask() {
2988
3142
  // --- MCP Server setup ---
2989
3143
 
2990
3144
  const server = new Server(
2991
- { name: 'agent-bridge', version: '3.8.0' },
3145
+ { name: 'agent-bridge', version: '3.9.0' },
2992
3146
  { capabilities: { tools: {} } }
2993
3147
  );
2994
3148
 
@@ -3039,6 +3193,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
3039
3193
  type: 'string',
3040
3194
  description: 'ID of a previous message to thread this reply under (optional)',
3041
3195
  },
3196
+ channel: {
3197
+ type: 'string',
3198
+ description: 'Channel to send to (optional — omit for #general). Use join_channel() first to create channels.',
3199
+ },
3042
3200
  },
3043
3201
  required: ['content'],
3044
3202
  },
@@ -3405,6 +3563,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
3405
3563
  properties: {},
3406
3564
  },
3407
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
+ },
3408
3582
  // --- Briefing & Recovery ---
3409
3583
  {
3410
3584
  name: 'get_guide',
@@ -3570,7 +3744,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3570
3744
  result = toolListAgents();
3571
3745
  break;
3572
3746
  case 'send_message':
3573
- result = await toolSendMessage(args.content, args?.to, args?.reply_to);
3747
+ result = await toolSendMessage(args.content, args?.to, args?.reply_to, args?.channel);
3574
3748
  break;
3575
3749
  case 'wait_for_reply':
3576
3750
  result = await toolWaitForReply(args?.timeout_seconds, args?.from);
@@ -3650,6 +3824,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3650
3824
  case 'listen_group':
3651
3825
  result = await toolListenGroup();
3652
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;
3653
3836
  case 'get_guide':
3654
3837
  result = toolGetGuide();
3655
3838
  break;
@@ -3813,7 +3996,7 @@ async function main() {
3813
3996
  ensureDataDir();
3814
3997
  const transport = new StdioServerTransport();
3815
3998
  await server.connect(transport);
3816
- console.error('Agent Bridge MCP server v3.8.0 running (53 tools)');
3999
+ console.error('Agent Bridge MCP server v3.9.0 running (56 tools)');
3817
4000
  }
3818
4001
 
3819
4002
  main().catch(console.error);