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 +52 -0
- package/README.md +19 -5
- package/cli.js +1 -1
- package/dashboard.html +3 -3
- package/dashboard.js +17 -4
- package/package.json +1 -1
- package/server.js +260 -39
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
|
-
- **
|
|
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,
|
|
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
|
|
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
|
-
- **
|
|
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 (
|
|
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
|
|
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": "/
|
|
15
|
-
"three/addons/": "/
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
return JSON.parse(fs.readFileSync(AGENTS_FILE, 'utf8'));
|
|
225
|
-
}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1929
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
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 >
|
|
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: '
|
|
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}
|
|
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}
|
|
4192
|
+
result._nudge = `URGENT: ${pending.length} messages waiting ${ageSec}s${addressedHint}: ${senderSummary}. Latest: "${preview}...". Call listen_group() soon.`;
|
|
3972
4193
|
} else {
|
|
3973
|
-
result._nudge =
|
|
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.
|
|
4253
|
+
console.error('Agent Bridge MCP server v3.10.1 running (56 tools)');
|
|
4033
4254
|
}
|
|
4034
4255
|
|
|
4035
4256
|
main().catch(console.error);
|