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/CHANGELOG.md +59 -0
- package/README.md +3 -3
- package/cli.js +1 -1
- package/dashboard.html +7480 -7399
- package/dashboard.js +8 -3
- package/office/animation.js +1 -0
- package/office/campus-env.js +1 -1
- package/office/environment.js +60 -67
- package/office/index.js +50 -0
- package/office/monitors.js +2 -2
- package/office/player.js +436 -0
- package/office/spectator-camera.js +30 -21
- package/package.json +1 -1
- package/server.js +432 -39
package/server.js
CHANGED
|
@@ -72,7 +72,13 @@ function isGroupMode() {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function getGroupCooldown() {
|
|
75
|
-
|
|
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
|
|
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
|
|
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
|
|
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 —
|
|
787
|
+
// Group mode cooldown — split by addressing (fast lane / slow lane)
|
|
726
788
|
if (isGroupMode()) {
|
|
727
|
-
|
|
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
|
-
|
|
843
|
-
|
|
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
|
-
//
|
|
848
|
-
//
|
|
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
|
-
|
|
1511
|
+
// Auto-detect direct mode and delegate to toolListen (prevents wrong-function bugs)
|
|
1512
|
+
if (!isGroupMode() && !isManagedMode()) {
|
|
1513
|
+
return toolListen();
|
|
1514
|
+
}
|
|
1381
1515
|
|
|
1382
|
-
|
|
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
|
|
1394
|
-
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));
|
|
1395
1539
|
const batch = [];
|
|
1396
1540
|
for (const msg of messages) {
|
|
1397
1541
|
if (consumed.has(msg.id)) continue;
|
|
1398
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
3999
|
+
console.error('Agent Bridge MCP server v3.9.0 running (56 tools)');
|
|
3607
4000
|
}
|
|
3608
4001
|
|
|
3609
4002
|
main().catch(console.error);
|