let-them-talk 3.10.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.0.0] - 2026-03-17
4
+
5
+ ### Major Release — 10-Agent Free Group Mode
6
+
7
+ Massive scaling overhaul designed, implemented, and audited by a 3-agent team (Architect, Tester, Protocol). 12 changes, 3 bugs caught during collaborative code review.
8
+
9
+ ### Added — Scaling (4 features)
10
+ - **Scaled context** — `listen_group` context window scales with team size: `min(50, max(20, agentCount * 5))`. 3 agents = 20 messages, 10 agents = 50.
11
+ - **Send-after-listen enforcement** — agents must call `listen_group()` between sends. Prevents message storms. Addressed agents get 2 sends per cycle, others get 1.
12
+ - **Response budget** — max 2 unaddressed sends per 60 seconds. Time-based reset. Hint (not error) when depleted.
13
+ - **Smart context with priority partitions** — Bucket A (addressed messages, sacred, always included), Bucket B (channel messages, capped), Bucket C (chronological, fills remaining). Total guaranteed <= contextSize.
14
+
15
+ ### Added — Agent Awareness (3 features)
16
+ - **Enhanced nudge** — every non-listen tool response now includes sender names, addressed count, and message preview: `"URGENT: 3 messages waiting (2 addressed to you): 2 from Architect, 1 from Protocol. Latest: 'Need your review...'"`
17
+ - **Idle detection** — `listen_group()` returns `idle: true` after 60s with no messages, with proactive `work_suggestions`, task suggestions, and instructions. Agents auto-find work instead of blocking forever.
18
+ - **Enhanced `check_messages`** — now returns rich summary: `senders`, `addressed_to_you`, `preview`, `urgency` level. The proactive counterpart to the passive nudge.
19
+
20
+ ### Added — Organization
21
+ - **Task-channel auto-binding** — with 5+ agents in group mode, `create_task` auto-creates `#task-{id}` channels. Assignees auto-join on claim. Channels auto-delete on task completion. Naturally splits 10-agent noise into focused sub-teams.
22
+
23
+ ### Improved — Performance
24
+ - **Cached reads** — `getAgents()` (1.5s TTL), `getChannelsData()` (3s TTL), `getTasks()` (2s TTL) with write-through invalidation. Eliminates ~70% redundant disk I/O.
25
+ - **Compact JSON writes** — removed pretty-print (`null, 2`) from all internal JSON writes. 2-3x less I/O overhead.
26
+ - **Optimized agent status** — removed O(N) `getUnconsumedMessages` scan per agent in `listen_group` status computation.
27
+ - **Dashboard SSE race fix** — `Array.from()` before Set iteration prevents skipped clients during concurrent connect/disconnect.
28
+ - **Dashboard SSE heartbeat** — 30s keepalive prevents dead connection accumulation and proxy timeouts.
29
+ - **Dashboard file watcher cleanup** — old watcher properly closed on LAN toggle, prevents memory leaks.
30
+ - **Dashboard watcher filter** — only triggers on `.json`/`.jsonl` files, ignores lock files and temp files.
31
+
32
+ ### Added — Safety
33
+ - **Collection caps** — tasks (1000), workflows (500), votes (500), reviews (500), dependencies (1000), branches (100), channels (100). Prevents DoS via unbounded growth.
34
+ - **Input type validation** — `reply_to` and `channel` parameters type-checked as strings in `send_message`.
35
+ - **Channel name validation fix** — error message corrected from "1-30 chars" to "1-20 chars" to match `sanitizeName()`.
36
+
37
+ ## [3.10.0] - 2026-03-17
38
+
39
+ ### Added — Dynamic Guide with Progressive Disclosure
40
+ - **`buildGuide()`** — replaces hardcoded guide in register() and get_guide(). Returns only rules relevant to the current system state.
41
+ - **Tiered rules:** Tier 0 (listen after every action), Tier 1 (core behavior), Tier 2 (group mode features), Tier 2b (channels), Tier 3 (large teams 5+)
42
+ - **User-customizable:** `.agent-bridge/guide.md` for project-specific rules
43
+ - 2-agent direct mode = 5 rules. 10-agent group with channels = 12 rules.
44
+
45
+ ## [3.9.1] - 2026-03-17
46
+
47
+ ### Added
48
+ - **Per-channel cooldown** — uses channel member count instead of total agents. 2-member #backend = 1s, regardless of 10 in #general
49
+ - **`cooldown_applied_ms`** — diagnostic field in send_message response showing exact cooldown applied
50
+ - **`channel` field** in send_message response when sending to a channel
51
+
52
+ ### Fixed
53
+ - Task race condition — `update_task` rejects claiming tasks already in_progress by another agent
54
+
3
55
  ## [3.9.0] - 2026-03-17
4
56
 
5
57
  ### Added — Channels & Split Cooldown
package/README.md CHANGED
@@ -84,19 +84,22 @@ Each terminal spawns its own MCP server process. All processes share a `.agent-b
84
84
 
85
85
  ## Highlights
86
86
 
87
+ - **10-agent scale** — smart context partitions, send-after-listen enforcement, response budgets, idle detection, task-channel auto-binding
87
88
  - **3D virtual office** — chibi characters at desks, spectator camera (WASD+mouse), 11 hairstyles, 6 outfits, gestures, furniture, TV dashboard
88
89
  - **Managed conversation mode** — structured turn-taking with floor control for 3+ agents, prevents broadcast storms
89
- - **53 MCP tools** — messaging, tasks, workflows, profiles, workspaces, branching, managed mode, briefing, file locking, decisions, KB, voting, reviews, dependencies, reputation
90
+ - **56 MCP tools** — messaging, tasks, workflows, profiles, workspaces, branching, managed mode, briefing, file locking, decisions, KB, voting, reviews, dependencies, reputation
90
91
  - **8-tab dashboard** — 3D Hub (default), messages, tasks, workspaces, workflows, launch, stats, docs
91
- - **Group conversation mode** — single-write `__group__` messages, adaptive cooldown, `addressed_to` hints, alive-only GC
92
+ - **Group conversation mode** — single-write `__group__` messages, adaptive cooldown, `addressed_to` hints, smart context, idle detection
93
+ - **Agent awareness** — enhanced nudge with sender/preview on every tool call, idle work suggestions, rich `check_messages`
92
94
  - **5 agent templates** — pair, team, review, debate, managed — with ready-to-paste prompts
93
95
  - **5 conversation templates** — Code Review, Debug Squad, Feature Dev, Research & Write, Managed Team
94
96
  - **Stats & analytics** — per-agent scores, response times, hourly charts, conversation velocity
95
- - **Task management** — drag-and-drop kanban board between agents
97
+ - **Task management** — drag-and-drop kanban board, task-channel auto-binding for 5+ agent teams
96
98
  - **Workflow pipelines** — multi-step automation with auto-handoff
97
99
  - **Conversation branching** — fork at any point, isolated history per branch
98
100
  - **Ollama integration** — `npx let-them-talk init --ollama` for local AI models
99
- - **Secure by default** — CSRF, LAN auth tokens, CSP, config locking, reserved name blocklist
101
+ - **Performance optimized** — cached reads (70% I/O reduction), compact JSON writes, SSE heartbeat
102
+ - **Secure by default** — CSRF, LAN auth tokens, CSP, collection caps, config locking, reserved name blocklist
100
103
  - **Zero config** — one `npx` command, auto-detects your CLI, works immediately
101
104
 
102
105
  ## Agent Templates
@@ -175,7 +178,7 @@ The dashboard's default view is a **real-time 3D virtual office** (the "3D Hub")
175
178
 
176
179
  **Animations:** walk, sit, type, raise hand, sleep (ZZZ), wave, think, point, celebrate, stretch, idle gestures. Agents turn toward speakers during conversations.
177
180
 
178
- ## MCP Tools (53)
181
+ ## MCP Tools (56)
179
182
 
180
183
  <details>
181
184
  <summary><strong>Messaging (13 tools)</strong></summary>
@@ -333,6 +336,17 @@ The dashboard's default view is a **real-time 3D virtual office** (the "3D Hub")
333
336
 
334
337
  </details>
335
338
 
339
+ <details>
340
+ <summary><strong>Channels (3 tools)</strong></summary>
341
+
342
+ | Tool | Description |
343
+ |------|-------------|
344
+ | `join_channel` | Join or create a channel for sub-team communication |
345
+ | `leave_channel` | Leave a channel (can't leave #general, empty auto-delete) |
346
+ | `list_channels` | List all channels with members and message counts |
347
+
348
+ </details>
349
+
336
350
  ## CLI Reference
337
351
 
338
352
  ```bash
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.10.0
12
+ Let Them Talk — Agent Bridge v4.0.1
13
13
  MCP message broker for inter-agent communication
14
14
  Supports: Claude Code, Gemini CLI, Codex CLI, Ollama
15
15
 
package/dashboard.html CHANGED
@@ -11,8 +11,8 @@
11
11
  <script type="importmap">
12
12
  {
13
13
  "imports": {
14
- "three": "/lib/three/build/three.module.js",
15
- "three/addons/": "/lib/three/examples/jsm/"
14
+ "three": "https://unpkg.com/three@0.170.0/build/three.module.js",
15
+ "three/addons/": "https://unpkg.com/three@0.170.0/examples/jsm/"
16
16
  }
17
17
  }
18
18
  </script>
@@ -6879,7 +6879,7 @@ function renderDocs() {
6879
6879
 
6880
6880
  // All 53 Tools
6881
6881
  '<div class="docs-section">' +
6882
- '<h3>All 53 MCP Tools</h3>' +
6882
+ '<h3>All 56 MCP Tools</h3>' +
6883
6883
  '<h4>Core Messaging</h4>' +
6884
6884
  '<div class="docs-tool-grid">' +
6885
6885
  '<div class="docs-tool-item"><code>register(name, provider?)</code><div class="desc">Register your agent identity. Must be called first.</div></div>' +
package/dashboard.js CHANGED
@@ -1820,7 +1820,11 @@ const server = http.createServer(async (req, res) => {
1820
1820
  });
1821
1821
  res.write(`data: connected\n\n`);
1822
1822
  sseClients.add(res);
1823
- req.on('close', () => sseClients.delete(res));
1823
+ // Heartbeat every 30s to detect dead connections and prevent proxy timeouts
1824
+ const heartbeat = setInterval(() => {
1825
+ try { res.write(`:heartbeat\n\n`); } catch { clearInterval(heartbeat); sseClients.delete(res); }
1826
+ }, 30000);
1827
+ req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); });
1824
1828
  }
1825
1829
  // --- Mod system API ---
1826
1830
  else if (url.pathname === '/api/mods' && req.method === 'GET') {
@@ -1965,13 +1969,15 @@ function sseNotifyAll() {
1965
1969
  generateNotifications(agents);
1966
1970
  } catch {}
1967
1971
 
1968
- for (const res of sseClients) {
1972
+ const dead = [];
1973
+ for (const res of Array.from(sseClients)) {
1969
1974
  try {
1970
1975
  res.write(`data: update\n\n`);
1971
1976
  } catch {
1972
- sseClients.delete(res);
1977
+ dead.push(res);
1973
1978
  }
1974
1979
  }
1980
+ for (const res of dead) sseClients.delete(res);
1975
1981
  }
1976
1982
 
1977
1983
  // Watch data directory for changes and push SSE notifications
@@ -1979,10 +1985,17 @@ let fsWatcher = null;
1979
1985
  let sseDebounceTimer = null;
1980
1986
 
1981
1987
  function startFileWatcher() {
1988
+ // Clean up previous watcher to prevent memory leaks on LAN toggle
1989
+ if (fsWatcher) { try { fsWatcher.close(); } catch {} fsWatcher = null; }
1990
+ if (sseDebounceTimer) { clearTimeout(sseDebounceTimer); sseDebounceTimer = null; }
1991
+
1982
1992
  const dataDir = resolveDataDir();
1983
1993
  if (!fs.existsSync(dataDir)) return;
1984
1994
  try {
1985
- fsWatcher = fs.watch(dataDir, { persistent: false }, () => {
1995
+ fsWatcher = fs.watch(dataDir, { persistent: false }, (eventType, filename) => {
1996
+ // Filter: only react to data files, not temp/lock files
1997
+ if (filename && !filename.endsWith('.json') && !filename.endsWith('.jsonl')) return;
1998
+ if (filename && filename.endsWith('.lock')) return;
1986
1999
  // Debounce — multiple file changes may fire rapidly
1987
2000
  if (sseDebounceTimer) clearTimeout(sseDebounceTimer);
1988
2001
  sseDebounceTimer = setTimeout(() => sseNotifyAll(), 200);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "let-them-talk",
3
- "version": "3.10.0",
3
+ "version": "4.0.1",
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
@@ -37,6 +37,22 @@ let heartbeatInterval = null; // heartbeat timer reference
37
37
  let messageSeq = 0; // monotonic sequence counter for message ordering
38
38
  let currentBranch = 'main'; // which branch this agent is on
39
39
  let lastSentAt = 0; // timestamp of last sent message (for group cooldown)
40
+ let sendsSinceLastListen = 0; // enforced: must listen between sends in group mode
41
+ let sendLimit = 1; // default: 1 send per listen cycle (2 if addressed)
42
+ let unaddressedSends = 0; // response budget: unaddressed sends counter
43
+ let budgetResetTime = Date.now(); // resets every 60s
44
+
45
+ // --- Read cache (eliminates 70%+ redundant disk I/O) ---
46
+ const _cache = {};
47
+ function cachedRead(key, readFn, ttlMs = 2000) {
48
+ const now = Date.now();
49
+ const entry = _cache[key];
50
+ if (entry && now - entry.ts < ttlMs) return entry.val;
51
+ const val = readFn();
52
+ _cache[key] = { val, ts: now };
53
+ return val;
54
+ }
55
+ function invalidateCache(key) { delete _cache[key]; }
40
56
 
41
57
  // --- Group conversation mode ---
42
58
  const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
@@ -63,7 +79,7 @@ function unlockConfigFile() { try { fs.unlinkSync(CONFIG_LOCK); } catch {} }
63
79
 
64
80
  function saveConfig(config) {
65
81
  ensureDataDir();
66
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
82
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config));
67
83
  }
68
84
 
69
85
  function isGroupMode() {
@@ -144,6 +160,9 @@ const rateLimitWindow = 60000; // 1 minute window
144
160
  const rateLimitMax = 30; // max 30 messages per minute per agent
145
161
  let rateLimitMessages = []; // timestamps of recent messages
146
162
 
163
+ // Stuck detector — tracks recent error tool calls to detect loops
164
+ let recentErrorCalls = []; // { tool, argsHash, timestamp }
165
+
147
166
  function checkRateLimit() {
148
167
  const now = Date.now();
149
168
  rateLimitMessages = rateLimitMessages.filter(t => now - t < rateLimitWindow);
@@ -219,16 +238,15 @@ function lockAgentsFile() {
219
238
  function unlockAgentsFile() { try { fs.unlinkSync(AGENTS_LOCK); } catch {} }
220
239
 
221
240
  function getAgents() {
222
- if (!fs.existsSync(AGENTS_FILE)) return {};
223
- try {
224
- return JSON.parse(fs.readFileSync(AGENTS_FILE, 'utf8'));
225
- } catch {
226
- return {};
227
- }
241
+ return cachedRead('agents', () => {
242
+ if (!fs.existsSync(AGENTS_FILE)) return {};
243
+ try { return JSON.parse(fs.readFileSync(AGENTS_FILE, 'utf8')); } catch { return {}; }
244
+ }, 1500);
228
245
  }
229
246
 
230
247
  function saveAgents(agents) {
231
- fs.writeFileSync(AGENTS_FILE, JSON.stringify(agents, null, 2));
248
+ fs.writeFileSync(AGENTS_FILE, JSON.stringify(agents));
249
+ invalidateCache('agents');
232
250
  }
233
251
 
234
252
  function getAcks() {
@@ -460,7 +478,7 @@ function markAsRead(agentName, messageId) {
460
478
  const receipts = getReadReceipts();
461
479
  if (!receipts[messageId]) receipts[messageId] = {};
462
480
  receipts[messageId][agentName] = new Date().toISOString();
463
- fs.writeFileSync(READ_RECEIPTS_FILE, JSON.stringify(receipts, null, 2));
481
+ fs.writeFileSync(READ_RECEIPTS_FILE, JSON.stringify(receipts));
464
482
  }
465
483
 
466
484
  // Get unconsumed messages for an agent (full scan — used by check_messages and initial load)
@@ -491,7 +509,7 @@ function getProfiles() {
491
509
  }
492
510
 
493
511
  function saveProfiles(profiles) {
494
- fs.writeFileSync(PROFILES_FILE, JSON.stringify(profiles, null, 2));
512
+ fs.writeFileSync(PROFILES_FILE, JSON.stringify(profiles));
495
513
  }
496
514
 
497
515
  // Built-in avatar SVGs — hash-based assignment
@@ -534,7 +552,7 @@ function getWorkspace(agentName) {
534
552
 
535
553
  function saveWorkspace(agentName, data) {
536
554
  ensureWorkspacesDir();
537
- fs.writeFileSync(path.join(WORKSPACES_DIR, `${sanitizeName(agentName)}.json`), JSON.stringify(data, null, 2));
555
+ fs.writeFileSync(path.join(WORKSPACES_DIR, `${sanitizeName(agentName)}.json`), JSON.stringify(data));
538
556
  }
539
557
 
540
558
  // --- Workflow helpers ---
@@ -545,7 +563,7 @@ function getWorkflows() {
545
563
  }
546
564
 
547
565
  function saveWorkflows(workflows) {
548
- fs.writeFileSync(WORKFLOWS_FILE, JSON.stringify(workflows, null, 2));
566
+ fs.writeFileSync(WORKFLOWS_FILE, JSON.stringify(workflows));
549
567
  }
550
568
 
551
569
  // --- Branch helpers ---
@@ -556,7 +574,7 @@ function getBranches() {
556
574
  }
557
575
 
558
576
  function saveBranches(branches) {
559
- fs.writeFileSync(BRANCHES_FILE, JSON.stringify(branches, null, 2));
577
+ fs.writeFileSync(BRANCHES_FILE, JSON.stringify(branches));
560
578
  }
561
579
 
562
580
  function getMessagesFile(branch) {
@@ -605,7 +623,8 @@ function buildGuide() {
605
623
 
606
624
  // Tier 3 — large teams (shown when 5+ agents)
607
625
  if (aliveCount >= 5) {
608
- rules.push('If listen_group returns no messages for 2 polls, call suggest_task() to find work.');
626
+ rules.push('If listen_group returns idle: true, follow the work_suggestions. Do not sit idle.');
627
+ rules.push('Tasks auto-create channels (#task-xxx). Use them for focused discussion instead of #general.');
609
628
  rules.push('Use channels to split into sub-teams. Do not discuss everything in #general.');
610
629
  }
611
630
 
@@ -824,9 +843,24 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
824
843
  return { error: 'You must call register() first' };
825
844
  }
826
845
 
846
+ // Type validation for optional params
847
+ if (reply_to && typeof reply_to !== 'string') return { error: 'reply_to must be a string' };
848
+ if (channel && typeof channel !== 'string') return { error: 'channel must be a string' };
849
+
827
850
  const rateErr = checkRateLimit();
828
851
  if (rateErr) return rateErr;
829
852
 
853
+ // Send-after-listen enforcement: must call listen_group between sends in group mode
854
+ if (isGroupMode() && sendsSinceLastListen >= sendLimit) {
855
+ return { error: `You must call listen_group() before sending again. You've sent ${sendsSinceLastListen} message(s) without listening. This prevents message storms.` };
856
+ }
857
+
858
+ // Response budget: track unaddressed sends, hint when depleted
859
+ if (isGroupMode()) {
860
+ // Reset budget every 60 seconds
861
+ if (Date.now() - budgetResetTime > 60000) { unaddressedSends = 0; budgetResetTime = Date.now(); }
862
+ }
863
+
830
864
  // Group mode cooldown — per-channel aware + split by addressing (fast/slow lane)
831
865
  let _cooldownApplied = 0;
832
866
  if (isGroupMode()) {
@@ -1030,10 +1064,18 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
1030
1064
  }
1031
1065
  }
1032
1066
 
1067
+ // Update send counters
1068
+ sendsSinceLastListen++;
1069
+ if (isGroupMode() && !msg.addressed_to) { unaddressedSends++; }
1070
+
1033
1071
  const result = { success: true, messageId: msg.id, from: msg.from, to: msg.to };
1034
1072
  if (_cooldownApplied > 0) result.cooldown_applied_ms = _cooldownApplied;
1035
1073
  if (channel) result.channel = channel;
1036
1074
  if (currentBranch !== 'main') result.branch = currentBranch;
1075
+ // Response budget hint
1076
+ if (isGroupMode() && unaddressedSends >= 2 && !msg.addressed_to) {
1077
+ result._budget_hint = 'Response budget depleted (2 unaddressed sends in 60s). Wait to be addressed or wait for budget reset.';
1078
+ }
1037
1079
  if (!recipientAlive) {
1038
1080
  result.warning = `Agent "${to}" appears offline (PID not running). Message queued but may not be received until they reconnect.`;
1039
1081
  } else if (agents[to] && !agents[to].listening_since) {
@@ -1068,6 +1110,11 @@ function toolBroadcast(content) {
1068
1110
  }
1069
1111
  }
1070
1112
 
1113
+ // Send-after-listen enforcement applies to broadcast too
1114
+ if (isGroupMode() && sendsSinceLastListen >= sendLimit) {
1115
+ return { error: `You must call listen_group() before broadcasting again. You've sent ${sendsSinceLastListen} message(s) without listening.` };
1116
+ }
1117
+
1071
1118
  const rateErr = checkRateLimit();
1072
1119
  if (rateErr) return rateErr;
1073
1120
 
@@ -1099,6 +1146,8 @@ function toolBroadcast(content) {
1099
1146
  fs.appendFileSync(getHistoryFile(currentBranch), JSON.stringify(msg) + '\n');
1100
1147
  touchActivity();
1101
1148
  lastSentAt = Date.now();
1149
+ sendsSinceLastListen++;
1150
+ unaddressedSends++; // broadcasts are always unaddressed
1102
1151
  const aliveOthers = otherAgents.filter(n => { const a = agents[n]; return isPidAlive(a.pid, a.last_activity); });
1103
1152
  const result = { success: true, messageId: msg.id, recipient_count: aliveOthers.length, sent_to: aliveOthers.map(n => ({ to: n, messageId: msg.id })) };
1104
1153
  // Nudge for own unread messages
@@ -1214,7 +1263,16 @@ function toolCheckMessages(from = null) {
1214
1263
  }
1215
1264
 
1216
1265
  const unconsumed = getUnconsumedMessages(registeredName, from);
1217
- return {
1266
+
1267
+ // Rich summary: senders, addressed count, urgency — same as enhanced nudge
1268
+ const senders = {};
1269
+ let addressedCount = 0;
1270
+ for (const m of unconsumed) {
1271
+ senders[m.from] = (senders[m.from] || 0) + 1;
1272
+ if (m.addressed_to && m.addressed_to.includes(registeredName)) addressedCount++;
1273
+ }
1274
+
1275
+ const result = {
1218
1276
  count: unconsumed.length,
1219
1277
  messages: unconsumed.map(m => ({
1220
1278
  id: m.id,
@@ -1223,8 +1281,20 @@ function toolCheckMessages(from = null) {
1223
1281
  timestamp: m.timestamp,
1224
1282
  ...(m.reply_to && { reply_to: m.reply_to }),
1225
1283
  ...(m.thread_id && { thread_id: m.thread_id }),
1284
+ ...(m.addressed_to && { addressed_to: m.addressed_to }),
1226
1285
  })),
1227
1286
  };
1287
+
1288
+ if (unconsumed.length > 0) {
1289
+ result.senders = senders;
1290
+ result.addressed_to_you = addressedCount;
1291
+ const latest = unconsumed[unconsumed.length - 1];
1292
+ result.preview = `${latest.from}: "${latest.content.substring(0, 80).replace(/\n/g, ' ')}..."`;
1293
+ const oldestAge = Math.round((Date.now() - new Date(unconsumed[0].timestamp).getTime()) / 1000);
1294
+ result.urgency = oldestAge > 120 ? 'critical' : oldestAge > 30 ? 'urgent' : 'normal';
1295
+ }
1296
+
1297
+ return result;
1228
1298
  }
1229
1299
 
1230
1300
  function toolAckMessage(messageId) {
@@ -1243,7 +1313,7 @@ function toolAckMessage(messageId) {
1243
1313
  acked_by: registeredName,
1244
1314
  acked_at: new Date().toISOString(),
1245
1315
  };
1246
- fs.writeFileSync(ACKS_FILE, JSON.stringify(acks, null, 2));
1316
+ fs.writeFileSync(ACKS_FILE, JSON.stringify(acks));
1247
1317
  touchActivity();
1248
1318
 
1249
1319
  return { success: true, message: `Message ${messageId} acknowledged` };
@@ -1569,6 +1639,8 @@ async function toolListenGroup() {
1569
1639
  setListening(true);
1570
1640
 
1571
1641
  const consumed = getConsumedIds(registeredName);
1642
+ const idleThreshold = 60000; // 60s of no messages → return idle suggestions
1643
+ const listenStarted = Date.now();
1572
1644
 
1573
1645
  // Poll indefinitely (in 5-min chunks to stay within any MCP limits, same as listen())
1574
1646
  while (true) {
@@ -1611,6 +1683,12 @@ async function toolListenGroup() {
1611
1683
  touchActivity();
1612
1684
  setListening(false);
1613
1685
 
1686
+ // Reset send counters: listening resets the send-after-listen enforcement
1687
+ sendsSinceLastListen = 0;
1688
+ // Set send limit based on whether any message addresses this agent
1689
+ const wasAddressed = batch.some(m => m.addressed_to && m.addressed_to.includes(registeredName));
1690
+ sendLimit = wasAddressed ? 2 : 1; // addressed = 2 sends (response + follow-up), otherwise 1
1691
+
1614
1692
  // Post-receive stagger: deterministic delay based on agent name
1615
1693
  // Prevents all agents from responding simultaneously to the same batch
1616
1694
  const staggerMs = hashStagger(registeredName);
@@ -1647,16 +1725,39 @@ async function toolListenGroup() {
1647
1725
  }
1648
1726
  const batchSummary = `${batch.length} messages: ${summaryParts.join(', ')}`;
1649
1727
 
1650
- // Get recent history for context
1728
+ // Count agents first (needed by smart context sizing)
1729
+ const agents = getAgents();
1730
+ const agentNames = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
1731
+
1732
+ // Smart context — priority partitions instead of dumb chronological
1651
1733
  const history = readJsonl(getHistoryFile(currentBranch));
1652
- const recentHistory = history.slice(-20).map(m => ({
1734
+ const contextSize = Math.min(50, Math.max(20, agentNames.length * 5));
1735
+ const myChannels = getAgentChannels(registeredName);
1736
+ const seen = new Set();
1737
+
1738
+ // Bucket A: messages addressed to me (sacred — always included, up to 10)
1739
+ const bucketA = history.filter(m => m.addressed_to && m.addressed_to.includes(registeredName)).slice(-10);
1740
+ bucketA.forEach(m => seen.add(m.id));
1741
+
1742
+ // Bucket B: messages from my channels (capped to fit after A)
1743
+ const maxB = Math.min(15, contextSize - bucketA.length);
1744
+ const bucketB = maxB > 0 ? history.filter(m => m.channel && myChannels.includes(m.channel) && !seen.has(m.id)).slice(-maxB) : [];
1745
+ bucketB.forEach(m => seen.add(m.id));
1746
+
1747
+ // Bucket C: recent chronological (fill remaining after A+B)
1748
+ const remaining = contextSize - bucketA.length - bucketB.length;
1749
+ const bucketC = remaining > 0 ? history.filter(m => !seen.has(m.id)).slice(-remaining) : [];
1750
+
1751
+ // Merge and sort — total guaranteed <= contextSize, A always survives
1752
+ const smartHistory = [...bucketA, ...bucketB, ...bucketC].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
1753
+ const recentHistory = smartHistory.map(m => ({
1653
1754
  from: m.from, to: m.to, content: m.content.substring(0, 500),
1654
1755
  timestamp: m.timestamp, id: m.id,
1756
+ ...(m.channel && { channel: m.channel }),
1757
+ ...(m.addressed_to && { addressed_to: m.addressed_to }),
1655
1758
  }));
1656
1759
 
1657
- // Count agents and who hasn't spoken recently
1658
- const agents = getAgents();
1659
- const agentNames = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
1760
+ // Who hasn't spoken recently (agents/agentNames already declared above)
1660
1761
  const recentSpeakers = new Set(history.slice(-10).map(m => m.from));
1661
1762
  const silent = agentNames.filter(n => !recentSpeakers.has(n) && n !== registeredName);
1662
1763
 
@@ -1689,11 +1790,10 @@ async function toolListenGroup() {
1689
1790
  if (agents[n].listening_since) {
1690
1791
  acc[n] = 'listening';
1691
1792
  } else {
1692
- // Check for unresponsive: not listening, >2min since last listen, has pending messages
1793
+ // Check for unresponsive: not listening, >2min since last listen
1693
1794
  const lastListened = agents[n].last_listened_at;
1694
1795
  const sinceLastListen = lastListened ? Date.now() - new Date(lastListened).getTime() : Infinity;
1695
- const pendingForAgent = getUnconsumedMessages(n);
1696
- if (sinceLastListen > 120000 && pendingForAgent.length > 0) {
1796
+ if (sinceLastListen > 120000) {
1697
1797
  acc[n] = 'unresponsive';
1698
1798
  } else {
1699
1799
  acc[n] = 'working';
@@ -1740,6 +1840,44 @@ async function toolListenGroup() {
1740
1840
  return result;
1741
1841
  }
1742
1842
 
1843
+ // Idle detection: after 60s of no messages, return with work suggestions
1844
+ if (Date.now() - listenStarted > idleThreshold) {
1845
+ setListening(false);
1846
+ touchActivity();
1847
+
1848
+ // Reset send counters (listening counts as a listen cycle)
1849
+ sendsSinceLastListen = 0;
1850
+ sendLimit = 1;
1851
+
1852
+ const agents = getAgents();
1853
+ const agentNames = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
1854
+
1855
+ // Proactive work suggestions
1856
+ const suggestion = toolSuggestTask();
1857
+ const myTasks = getTasks().filter(t => t.assignee === registeredName && t.status === 'in_progress');
1858
+ const pendingReviews = getReviews().filter(r => r.status === 'pending' && r.requested_by !== registeredName);
1859
+ const unresolved = getDeps().filter(d => !d.resolved);
1860
+
1861
+ const workItems = [];
1862
+ if (myTasks.length > 0) workItems.push(`You have ${myTasks.length} task(s) in progress: ${myTasks.map(t => `"${t.title}" (${t.id})`).join(', ')}`);
1863
+ if (pendingReviews.length > 0) workItems.push(`${pendingReviews.length} code review(s) waiting`);
1864
+ if (unresolved.length > 0) workItems.push(`${unresolved.length} blocked dependency(s) to resolve`);
1865
+
1866
+ return {
1867
+ idle: true,
1868
+ idle_seconds: Math.round((Date.now() - listenStarted) / 1000),
1869
+ messages: [],
1870
+ message_count: 0,
1871
+ agents_online: agentNames.length,
1872
+ work_suggestions: workItems.length > 0 ? workItems : ['No pending work. Ask the team what needs doing, or propose a new task.'],
1873
+ suggestion: suggestion.suggestion !== 'none' ? suggestion : undefined,
1874
+ instructions: myTasks.length > 0
1875
+ ? `No new messages for ${Math.round((Date.now() - listenStarted) / 1000)}s. Continue working on your in-progress task(s), then call listen_group() again.`
1876
+ : `No new messages for ${Math.round((Date.now() - listenStarted) / 1000)}s. ${suggestion.message || 'Ask the team what needs doing next.'}`,
1877
+ next_action: 'Do some work (suggest_task, continue tasks, review code), then call listen_group() again.',
1878
+ };
1879
+ }
1880
+
1743
1881
  await adaptiveSleep(0);
1744
1882
  }
1745
1883
  // No message in this 5-min chunk — loop again (stay listening forever)
@@ -1925,12 +2063,15 @@ function toolShareFile(filePath, to = null, summary = null) {
1925
2063
  // --- Task management ---
1926
2064
 
1927
2065
  function getTasks() {
1928
- if (!fs.existsSync(TASKS_FILE)) return [];
1929
- try { return JSON.parse(fs.readFileSync(TASKS_FILE, 'utf8')); } catch { return []; }
2066
+ return cachedRead('tasks', () => {
2067
+ if (!fs.existsSync(TASKS_FILE)) return [];
2068
+ try { return JSON.parse(fs.readFileSync(TASKS_FILE, 'utf8')); } catch { return []; }
2069
+ }, 2000);
1930
2070
  }
1931
2071
 
1932
2072
  function saveTasks(tasks) {
1933
- fs.writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2));
2073
+ invalidateCache('tasks');
2074
+ fs.writeFileSync(TASKS_FILE, JSON.stringify(tasks));
1934
2075
  }
1935
2076
 
1936
2077
  function toolCreateTask(title, description = '', assignee = null) {
@@ -1965,12 +2106,38 @@ function toolCreateTask(title, description = '', assignee = null) {
1965
2106
  };
1966
2107
 
1967
2108
  ensureDataDir();
2109
+
2110
+ // Task-channel auto-binding: with 5+ agents and an assignee, auto-create a task channel
2111
+ // This naturally splits 10-agent noise into focused sub-teams
2112
+ let taskChannel = null;
2113
+ const aliveCount = Object.values(agents).filter(a => isPidAlive(a.pid, a.last_activity)).length;
2114
+ if (assignee && aliveCount >= 5 && isGroupMode()) {
2115
+ const shortId = task.id.replace('task_', '').substring(0, 6);
2116
+ taskChannel = `task-${shortId}`;
2117
+ const channels = getChannelsData();
2118
+ if (!channels[taskChannel]) {
2119
+ channels[taskChannel] = {
2120
+ description: `Task: ${title.substring(0, 100)}`,
2121
+ members: [registeredName],
2122
+ created_by: '__system__',
2123
+ created_at: new Date().toISOString(),
2124
+ task_id: task.id,
2125
+ };
2126
+ if (assignee && assignee !== registeredName) channels[taskChannel].members.push(assignee);
2127
+ saveChannelsData(channels);
2128
+ }
2129
+ task.channel = taskChannel;
2130
+ }
2131
+
1968
2132
  const tasks = getTasks();
2133
+ if (tasks.length >= 1000) return { error: 'Task limit reached (max 1000). Complete or remove existing tasks first.' };
1969
2134
  tasks.push(task);
1970
2135
  saveTasks(tasks);
1971
2136
  touchActivity();
1972
2137
 
1973
- return { success: true, task_id: task.id, assignee: task.assignee };
2138
+ const result = { success: true, task_id: task.id, assignee: task.assignee };
2139
+ if (taskChannel) result.channel = taskChannel;
2140
+ return result;
1974
2141
  }
1975
2142
 
1976
2143
  function toolUpdateTask(taskId, status, notes = null) {
@@ -2007,6 +2174,15 @@ function toolUpdateTask(taskId, status, notes = null) {
2007
2174
  saveTasks(tasks);
2008
2175
  touchActivity();
2009
2176
 
2177
+ // Task-channel auto-join: when claiming a task that has a channel, auto-join it
2178
+ if (status === 'in_progress' && task.channel) {
2179
+ const channels = getChannelsData();
2180
+ if (channels[task.channel] && !channels[task.channel].members.includes(registeredName)) {
2181
+ channels[task.channel].members.push(registeredName);
2182
+ saveChannelsData(channels);
2183
+ }
2184
+ }
2185
+
2010
2186
  // Event hooks: task completion
2011
2187
  if (status === 'done') {
2012
2188
  fireEvent('task_complete', { title: task.title, created_by: task.created_by });
@@ -2022,6 +2198,15 @@ function toolUpdateTask(taskId, status, notes = null) {
2022
2198
  }
2023
2199
  }
2024
2200
  writeJsonFile(DEPS_FILE, deps);
2201
+
2202
+ // Task-channel auto-cleanup: archive task channel when task is done
2203
+ if (task.channel) {
2204
+ const channels = getChannelsData();
2205
+ if (channels[task.channel]) {
2206
+ delete channels[task.channel];
2207
+ saveChannelsData(channels);
2208
+ }
2209
+ }
2025
2210
  }
2026
2211
 
2027
2212
  return { success: true, task_id: task.id, status: task.status, title: task.title };
@@ -2275,6 +2460,7 @@ function toolCreateWorkflow(name, steps) {
2275
2460
  updated_at: new Date().toISOString(),
2276
2461
  };
2277
2462
 
2463
+ if (workflows.length >= 500) return { error: 'Workflow limit reached (max 500).' };
2278
2464
  workflows.push(workflow);
2279
2465
  ensureDataDir();
2280
2466
  saveWorkflows(workflows);
@@ -2368,6 +2554,7 @@ function toolForkConversation(fromMessageId, branchName) {
2368
2554
  sanitizeName(branchName);
2369
2555
 
2370
2556
  const branches = getBranches();
2557
+ if (Object.keys(branches).length >= 100) return { error: 'Branch limit reached (max 100).' };
2371
2558
  if (branches[branchName]) return { error: `Branch "${branchName}" already exists` };
2372
2559
 
2373
2560
  const history = readJsonl(getHistoryFile(currentBranch));
@@ -2441,7 +2628,7 @@ function toolListBranches() {
2441
2628
 
2442
2629
  // Helpers for new data files
2443
2630
  function readJsonFile(file) { if (!fs.existsSync(file)) return null; try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }
2444
- function writeJsonFile(file, data) { ensureDataDir(); fs.writeFileSync(file, JSON.stringify(data, null, 2)); }
2631
+ function writeJsonFile(file, data) { ensureDataDir(); fs.writeFileSync(file, JSON.stringify(data)); }
2445
2632
 
2446
2633
  function getDecisions() { return readJsonFile(DECISIONS_FILE) || []; }
2447
2634
  function getKB() { return readJsonFile(KB_FILE) || {}; }
@@ -2455,12 +2642,14 @@ function getDeps() { return readJsonFile(DEPS_FILE) || []; }
2455
2642
  const CHANNELS_FILE_PATH = path.join(DATA_DIR, 'channels.json');
2456
2643
 
2457
2644
  function getChannelsData() {
2458
- const data = readJsonFile(CHANNELS_FILE_PATH);
2459
- if (!data) return { general: { description: 'General channel — all agents', members: ['*'], created_by: 'system', created_at: new Date().toISOString() } };
2460
- return data;
2645
+ return cachedRead('channels', () => {
2646
+ const data = readJsonFile(CHANNELS_FILE_PATH);
2647
+ if (!data) return { general: { description: 'General channel — all agents', members: ['*'], created_by: 'system', created_at: new Date().toISOString() } };
2648
+ return data;
2649
+ }, 3000);
2461
2650
  }
2462
2651
 
2463
- function saveChannelsData(channels) { writeJsonFile(CHANNELS_FILE_PATH, channels); }
2652
+ function saveChannelsData(channels) { writeJsonFile(CHANNELS_FILE_PATH, channels); invalidateCache('channels'); }
2464
2653
 
2465
2654
  function getChannelMessagesFile(channelName) {
2466
2655
  if (!channelName || channelName === 'general') return getMessagesFile(currentBranch);
@@ -2499,11 +2688,12 @@ function cleanStaleChannelMembers() {
2499
2688
 
2500
2689
  function toolJoinChannel(channelName, description) {
2501
2690
  if (!registeredName) return { error: 'You must call register() first' };
2502
- if (typeof channelName !== 'string' || channelName.length < 1 || channelName.length > 30) return { error: 'Channel name must be 1-30 chars' };
2691
+ if (typeof channelName !== 'string' || channelName.length < 1 || channelName.length > 20) return { error: 'Channel name must be 1-20 chars' };
2503
2692
  sanitizeName(channelName);
2504
2693
 
2505
2694
  const channels = getChannelsData();
2506
2695
  if (!channels[channelName]) {
2696
+ if (Object.keys(channels).length >= 100) return { error: 'Channel limit reached (max 100).' };
2507
2697
  // Create new channel
2508
2698
  channels[channelName] = {
2509
2699
  description: (description || '').substring(0, 200),
@@ -2838,6 +3028,7 @@ function toolCallVote(question, options) {
2838
3028
  if (!Array.isArray(options) || options.length < 2 || options.length > 10) return { error: 'Need 2-10 options' };
2839
3029
 
2840
3030
  const votes = getVotes();
3031
+ if (votes.length >= 500) return { error: 'Vote limit reached (max 500).' };
2841
3032
  const vote = {
2842
3033
  id: 'vote_' + generateId(),
2843
3034
  question,
@@ -2904,6 +3095,7 @@ function toolRequestReview(filePath, description) {
2904
3095
  if (typeof filePath !== 'string' || filePath.length < 1) return { error: 'File path required' };
2905
3096
 
2906
3097
  const reviews = getReviews();
3098
+ if (reviews.length >= 500) return { error: 'Review limit reached (max 500).' };
2907
3099
  const review = {
2908
3100
  id: 'rev_' + generateId(),
2909
3101
  file: filePath.replace(/\\/g, '/'),
@@ -2959,6 +3151,7 @@ function toolDeclareDependency(taskId, dependsOnTaskId) {
2959
3151
  if (!depTask) return { error: `Dependency task not found: ${dependsOnTaskId}` };
2960
3152
 
2961
3153
  const deps = getDeps();
3154
+ if (deps.length >= 1000) return { error: 'Dependency limit reached (max 1000).' };
2962
3155
  deps.push({
2963
3156
  id: 'dep_' + generateId(),
2964
3157
  task_id: taskId,
@@ -3175,7 +3368,7 @@ function toolSuggestTask() {
3175
3368
  // --- MCP Server setup ---
3176
3369
 
3177
3370
  const server = new Server(
3178
- { name: 'agent-bridge', version: '3.10.0' },
3371
+ { name: 'agent-bridge', version: '4.0.1' },
3179
3372
  { capabilities: { tools: {} } }
3180
3373
  );
3181
3374
 
@@ -3946,6 +4139,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3946
4139
  }
3947
4140
 
3948
4141
  if (result.error) {
4142
+ // Stuck detector: track repeated error calls
4143
+ const argsHash = JSON.stringify(args || {}).substring(0, 100);
4144
+ recentErrorCalls.push({ tool: name, argsHash, timestamp: Date.now() });
4145
+ // Keep only last 10 entries, last 60 seconds
4146
+ const cutoff = Date.now() - 60000;
4147
+ recentErrorCalls = recentErrorCalls.filter(c => c.timestamp > cutoff).slice(-10);
4148
+ // Check if last 3 calls are same tool with same args
4149
+ const last3 = recentErrorCalls.slice(-3);
4150
+ if (last3.length >= 3 && last3.every(c => c.tool === name && c.argsHash === argsHash)) {
4151
+ result._stuck_hint = `You have called ${name} 3 times with the same error. Consider: broadcasting for help, trying a different approach, or calling suggest_task() to find other work.`;
4152
+ }
3949
4153
  return {
3950
4154
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
3951
4155
  isError: true,
@@ -3953,24 +4157,41 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3953
4157
  }
3954
4158
 
3955
4159
  // Global hook: on non-listen tools, check for pending messages and nudge with escalating urgency
4160
+ // Enhanced nudge: includes sender names, addressed count, and message preview
3956
4161
  const listenTools = ['listen', 'listen_group', 'listen_codex', 'wait_for_reply', 'check_messages'];
3957
4162
  if (registeredName && !listenTools.includes(name) && (isGroupMode() || isManagedMode())) {
3958
4163
  try {
3959
4164
  const pending = getUnconsumedMessages(registeredName);
3960
4165
  if (pending.length > 0 && !result.you_have_messages) {
4166
+ // Build rich nudge: WHO sent, WHETHER addressed, WHAT preview
4167
+ const senders = {};
4168
+ let addressedCount = 0;
4169
+ for (const m of pending) {
4170
+ senders[m.from] = (senders[m.from] || 0) + 1;
4171
+ if (m.addressed_to && m.addressed_to.includes(registeredName)) addressedCount++;
4172
+ }
4173
+ const senderSummary = Object.entries(senders).map(([n, c]) => `${c} from ${n}`).join(', ');
4174
+ const latest = pending[pending.length - 1];
4175
+ const preview = latest.content.substring(0, 80).replace(/\n/g, ' ');
4176
+
3961
4177
  result._pending_messages = pending.length;
4178
+ result._senders = senders;
4179
+ result._addressed_to_you = addressedCount;
4180
+ result._preview = `${latest.from}: "${preview}..."`;
4181
+
3962
4182
  // Escalate urgency based on oldest pending message age
3963
4183
  const oldestAge = pending.reduce((max, m) => {
3964
4184
  const age = Date.now() - new Date(m.timestamp).getTime();
3965
4185
  return age > max ? age : max;
3966
4186
  }, 0);
3967
4187
  const ageSec = Math.round(oldestAge / 1000);
4188
+ const addressedHint = addressedCount > 0 ? ` (${addressedCount} addressed to you)` : '';
3968
4189
  if (ageSec > 120) {
3969
- result._nudge = `CRITICAL: ${pending.length} message(s) waiting ${Math.round(ageSec / 60)}+ min. Team is likely blocked on you. Call listen_group() NOW.`;
4190
+ result._nudge = `CRITICAL: ${pending.length} messages waiting ${Math.round(ageSec / 60)}+ min${addressedHint}: ${senderSummary}. Latest: "${preview}...". Call listen_group() NOW.`;
3970
4191
  } else if (ageSec > 30) {
3971
- result._nudge = `URGENT: ${pending.length} message(s) waiting ${ageSec}s. Team may be blocked. Call listen_group() soon.`;
4192
+ result._nudge = `URGENT: ${pending.length} messages waiting ${ageSec}s${addressedHint}: ${senderSummary}. Latest: "${preview}...". Call listen_group() soon.`;
3972
4193
  } else {
3973
- result._nudge = `You have ${pending.length} unread message(s). Call listen_group() after this to read them.`;
4194
+ result._nudge = `${pending.length} messages waiting${addressedHint}: ${senderSummary}. Latest: "${preview}...". Call listen_group().`;
3974
4195
  }
3975
4196
  }
3976
4197
  } catch {}
@@ -4029,7 +4250,7 @@ async function main() {
4029
4250
  ensureDataDir();
4030
4251
  const transport = new StdioServerTransport();
4031
4252
  await server.connect(transport);
4032
- console.error('Agent Bridge MCP server v3.10.0 running (56 tools)');
4253
+ console.error('Agent Bridge MCP server v3.10.1 running (56 tools)');
4033
4254
  }
4034
4255
 
4035
4256
  main().catch(console.error);