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.
- package/CHANGELOG.md +17 -0
- package/cli.js +1 -1
- package/package.json +1 -1
- 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.
|
|
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
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 —
|
|
787
|
+
// Group mode cooldown — split by addressing (fast lane / slow lane)
|
|
787
788
|
if (isGroupMode()) {
|
|
788
|
-
|
|
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
|
-
|
|
907
|
-
|
|
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
|
|
1499
|
-
const
|
|
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.
|
|
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.
|
|
3999
|
+
console.error('Agent Bridge MCP server v3.9.0 running (56 tools)');
|
|
3817
4000
|
}
|
|
3818
4001
|
|
|
3819
4002
|
main().catch(console.error);
|