let-them-talk 3.4.4 → 3.5.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 +30 -0
- package/LICENSE +1 -1
- package/README.md +9 -8
- package/cli.js +152 -3
- package/dashboard.html +185 -3
- package/dashboard.js +287 -7
- package/package.json +1 -1
- package/server.js +227 -31
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.5.0] - 2026-03-15
|
|
4
|
+
|
|
5
|
+
### Added — Group Conversation Mode
|
|
6
|
+
- **`set_conversation_mode("group")`** — enables free multi-agent collaboration with auto-broadcast
|
|
7
|
+
- **`listen_group()`** — batch message receiver with random stagger (1-3s) to prevent simultaneous responses
|
|
8
|
+
- Returns ALL unconsumed messages + last 20 messages of context + hints about silent agents
|
|
9
|
+
- Auto-broadcast in group mode: every message is shared with all agents automatically
|
|
10
|
+
- Cooldown enforcement: agents must wait 3s between sends to maintain conversation flow
|
|
11
|
+
- Cascade prevention: broadcast copies don't trigger further broadcasts
|
|
12
|
+
- MCP tools: 27 → 29
|
|
13
|
+
|
|
14
|
+
### Added — Dashboard Features
|
|
15
|
+
- **Notification panel** — bell icon with badge count, dropdown event feed (agent online/offline, listening status changes)
|
|
16
|
+
- **Agent leaderboard** — performance scoring (0-100) with responsiveness, activity, reliability, collaboration dimensions
|
|
17
|
+
- **Cross-project search** — "All Projects" toggle in search bar, searches across all registered projects
|
|
18
|
+
- **Animated replay export** — Export conversation as self-playing HTML file with typing animations and play/pause controls
|
|
19
|
+
- **Ollama integration** — `npx let-them-talk init --ollama` auto-detects Ollama, creates bridge script for local models
|
|
20
|
+
|
|
21
|
+
### Fixed — PID & Registration Integrity
|
|
22
|
+
- Registration file locking with try/finally (prevents race conditions when multiple agents register simultaneously)
|
|
23
|
+
- PID stale detection uses `last_activity` with 30s threshold (prevents false "alive" from Windows PID reuse)
|
|
24
|
+
- Lock file cleaned up on process exit
|
|
25
|
+
- Dashboard inject/nudge snapshots project context at click time (prevents wrong-project race)
|
|
26
|
+
|
|
27
|
+
### Security
|
|
28
|
+
- `toolHandoff` and workflow auto-handoff now check `canSendTo` permissions
|
|
29
|
+
- `lastSentAt` updated in `toolBroadcast` (prevents cooldown bypass)
|
|
30
|
+
- `config.json` added to both server and dashboard reset cleanup
|
|
31
|
+
- Auto-broadcast respects `canSendTo` per recipient
|
|
32
|
+
|
|
3
33
|
## [3.4.4] - 2026-03-15
|
|
4
34
|
|
|
5
35
|
### Fixed
|
package/LICENSE
CHANGED
|
@@ -6,7 +6,7 @@ License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
|
|
6
6
|
Parameters
|
|
7
7
|
|
|
8
8
|
Licensor: Dekelelz
|
|
9
|
-
Licensed Work: Let Them Talk v3.
|
|
9
|
+
Licensed Work: Let Them Talk v3.5.0
|
|
10
10
|
The Licensed Work is (c) 2024-2026 Dekelelz.
|
|
11
11
|
Additional Use Grant: You may make use of the Licensed Work, provided that
|
|
12
12
|
you may not use the Licensed Work for a Commercial
|
package/README.md
CHANGED
|
@@ -84,19 +84,20 @@ Each terminal spawns its own MCP server process. All processes share a `.agent-b
|
|
|
84
84
|
|
|
85
85
|
## Highlights
|
|
86
86
|
|
|
87
|
-
- **
|
|
88
|
-
- **
|
|
89
|
-
- **
|
|
90
|
-
- **
|
|
87
|
+
- **29 MCP tools** — messaging, tasks, workflows, profiles, workspaces, branching, group chat
|
|
88
|
+
- **Group conversation mode** — free multi-agent collaboration with auto-broadcast, stagger delays, and cooldown
|
|
89
|
+
- **Premium dashboard** — glassmorphism UI, notifications panel, agent leaderboard, cross-project search
|
|
90
|
+
- **Animated replay export** — export conversations as self-playing HTML with typing animations
|
|
91
|
+
- **Ollama integration** — `npx let-them-talk init --ollama` for local AI models
|
|
92
|
+
- **Stats & analytics** — per-agent scores, response times, hourly charts, conversation velocity
|
|
93
|
+
- **Conversation templates** — 4 built-in workflows (Code Review, Debug Squad, Feature Dev, Research & Write)
|
|
91
94
|
- **Message management** — edit, delete, copy messages with full edit history
|
|
92
95
|
- **Task management** — drag-and-drop kanban board between agents
|
|
93
96
|
- **Workflow pipelines** — multi-step automation with auto-handoff
|
|
94
97
|
- **Agent profiles** — display names, SVG avatars, roles, bios
|
|
95
98
|
- **Conversation branching** — fork at any point, isolated history per branch
|
|
96
|
-
- **
|
|
97
|
-
- **
|
|
98
|
-
- **CLI tools** — send messages and check status directly from the command line
|
|
99
|
-
- **Secure by default** — CSRF protection, LAN auth tokens, Content Security Policy, agent permissions
|
|
99
|
+
- **Multi-format export** — HTML, Markdown, JSON, and animated replay
|
|
100
|
+
- **Secure by default** — CSRF, LAN auth tokens, CSP, permissions, registration locking
|
|
100
101
|
- **Zero config** — one `npx` command, auto-detects your CLI, works immediately
|
|
101
102
|
|
|
102
103
|
## Agent Templates
|
package/cli.js
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const os = require('os');
|
|
6
|
+
const { execSync } = require('child_process');
|
|
6
7
|
|
|
7
8
|
const command = process.argv[2];
|
|
8
9
|
|
|
9
10
|
function printUsage() {
|
|
10
11
|
console.log(`
|
|
11
|
-
Let Them Talk — Agent Bridge v3.
|
|
12
|
+
Let Them Talk — Agent Bridge v3.5.0
|
|
12
13
|
MCP message broker for inter-agent communication
|
|
13
|
-
Supports: Claude Code, Gemini CLI, Codex CLI
|
|
14
|
+
Supports: Claude Code, Gemini CLI, Codex CLI, Ollama
|
|
14
15
|
|
|
15
16
|
Usage:
|
|
16
17
|
npx let-them-talk init Auto-detect CLI and configure MCP
|
|
@@ -18,7 +19,8 @@ function printUsage() {
|
|
|
18
19
|
npx let-them-talk init --gemini Configure for Gemini CLI
|
|
19
20
|
npx let-them-talk init --codex Configure for Codex CLI
|
|
20
21
|
npx let-them-talk init --all Configure for all supported CLIs
|
|
21
|
-
npx let-them-talk init --
|
|
22
|
+
npx let-them-talk init --ollama Setup Ollama agent bridge (local LLM)
|
|
23
|
+
npx let-them-talk init --template T Initialize with a team template (pair, team, review, debate, ollama)
|
|
22
24
|
npx let-them-talk templates List available agent templates
|
|
23
25
|
npx let-them-talk dashboard Launch the web dashboard (http://localhost:3000)
|
|
24
26
|
npx let-them-talk dashboard --lan Launch dashboard accessible on LAN (phone/tablet)
|
|
@@ -52,6 +54,16 @@ function detectCLIs() {
|
|
|
52
54
|
return detected;
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
// Detect Ollama installation
|
|
58
|
+
function detectOllama() {
|
|
59
|
+
try {
|
|
60
|
+
const version = execSync('ollama --version', { encoding: 'utf8', timeout: 5000 }).trim();
|
|
61
|
+
return { installed: true, version };
|
|
62
|
+
} catch {
|
|
63
|
+
return { installed: false };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
// The data directory where all agents read/write — must be the same for server + dashboard
|
|
56
68
|
function dataDir(cwd) {
|
|
57
69
|
return path.join(cwd, '.agent-bridge').replace(/\\/g, '/');
|
|
@@ -147,6 +159,131 @@ AGENT_BRIDGE_DATA_DIR = ${JSON.stringify(dataDir(cwd))}
|
|
|
147
159
|
console.log(' [ok] Codex CLI: .codex/config.toml updated');
|
|
148
160
|
}
|
|
149
161
|
|
|
162
|
+
// Setup Ollama agent bridge script
|
|
163
|
+
function setupOllama(serverPath, cwd) {
|
|
164
|
+
const dir = dataDir(cwd);
|
|
165
|
+
const scriptPath = path.join(cwd, '.agent-bridge', 'ollama-agent.js');
|
|
166
|
+
|
|
167
|
+
if (!fs.existsSync(path.join(cwd, '.agent-bridge'))) {
|
|
168
|
+
fs.mkdirSync(path.join(cwd, '.agent-bridge'), { recursive: true });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const script = `#!/usr/bin/env node
|
|
172
|
+
// ollama-agent.js - bridges Ollama to Let Them Talk
|
|
173
|
+
// Usage: node .agent-bridge/ollama-agent.js [agent-name] [model]
|
|
174
|
+
const fs = require('fs'), path = require('path'), http = require('http');
|
|
175
|
+
const DATA_DIR = path.join(__dirname);
|
|
176
|
+
const name = process.argv[2] || 'Ollama';
|
|
177
|
+
const model = process.argv[3] || 'llama3';
|
|
178
|
+
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
|
|
179
|
+
|
|
180
|
+
function readJson(f) { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return {}; } }
|
|
181
|
+
function readJsonl(f) { if (!fs.existsSync(f)) return []; return fs.readFileSync(f, 'utf8').split('\\n').filter(l => l.trim()).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); }
|
|
182
|
+
|
|
183
|
+
// Register agent
|
|
184
|
+
function register() {
|
|
185
|
+
const agentsFile = path.join(DATA_DIR, 'agents.json');
|
|
186
|
+
const agents = readJson(agentsFile);
|
|
187
|
+
agents[name] = { pid: process.pid, timestamp: new Date().toISOString(), last_activity: new Date().toISOString(), provider: 'Ollama (' + model + ')' };
|
|
188
|
+
fs.writeFileSync(agentsFile, JSON.stringify(agents, null, 2));
|
|
189
|
+
console.log('[' + name + '] Registered (PID ' + process.pid + ', model: ' + model + ')');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Update heartbeat
|
|
193
|
+
function heartbeat() {
|
|
194
|
+
const agentsFile = path.join(DATA_DIR, 'agents.json');
|
|
195
|
+
const agents = readJson(agentsFile);
|
|
196
|
+
if (agents[name]) {
|
|
197
|
+
agents[name].last_activity = new Date().toISOString();
|
|
198
|
+
agents[name].pid = process.pid;
|
|
199
|
+
fs.writeFileSync(agentsFile, JSON.stringify(agents, null, 2));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Call Ollama API
|
|
204
|
+
function callOllama(prompt) {
|
|
205
|
+
return new Promise(function(resolve, reject) {
|
|
206
|
+
const url = new URL(OLLAMA_URL + '/api/chat');
|
|
207
|
+
const body = JSON.stringify({ model: model, messages: [{ role: 'user', content: prompt }], stream: false });
|
|
208
|
+
const req = http.request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' } }, function(res) {
|
|
209
|
+
let data = '';
|
|
210
|
+
res.on('data', function(c) { data += c; });
|
|
211
|
+
res.on('end', function() {
|
|
212
|
+
try { const j = JSON.parse(data); resolve(j.message ? j.message.content : data); }
|
|
213
|
+
catch { resolve(data); }
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
req.on('error', reject);
|
|
217
|
+
req.write(body);
|
|
218
|
+
req.end();
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Send a message
|
|
223
|
+
function sendMessage(to, content) {
|
|
224
|
+
const msgId = 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
225
|
+
const msg = { id: msgId, from: name, to: to, content: content, timestamp: new Date().toISOString() };
|
|
226
|
+
fs.appendFileSync(path.join(DATA_DIR, 'messages.jsonl'), JSON.stringify(msg) + '\\n');
|
|
227
|
+
fs.appendFileSync(path.join(DATA_DIR, 'history.jsonl'), JSON.stringify(msg) + '\\n');
|
|
228
|
+
console.log('[' + name + '] -> ' + to + ': ' + content.substring(0, 80) + (content.length > 80 ? '...' : ''));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Listen for messages
|
|
232
|
+
let lastOffset = 0;
|
|
233
|
+
function checkMessages() {
|
|
234
|
+
const consumedFile = path.join(DATA_DIR, 'consumed-' + name + '.json');
|
|
235
|
+
const consumed = readJson(consumedFile);
|
|
236
|
+
lastOffset = consumed.offset || 0;
|
|
237
|
+
|
|
238
|
+
const messages = readJsonl(path.join(DATA_DIR, 'messages.jsonl'));
|
|
239
|
+
const newMsgs = messages.slice(lastOffset).filter(function(m) {
|
|
240
|
+
return m.to === name || (m.to === 'all' && m.from !== name);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (newMsgs.length > 0) {
|
|
244
|
+
consumed.offset = messages.length;
|
|
245
|
+
fs.writeFileSync(consumedFile, JSON.stringify(consumed));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return newMsgs;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function processMessages() {
|
|
252
|
+
const msgs = checkMessages();
|
|
253
|
+
for (const m of msgs) {
|
|
254
|
+
console.log('[' + name + '] <- ' + m.from + ': ' + m.content.substring(0, 80));
|
|
255
|
+
try {
|
|
256
|
+
const response = await callOllama(m.content);
|
|
257
|
+
sendMessage(m.from, response);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
sendMessage(m.from, 'Error calling Ollama: ' + e.message);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Main loop
|
|
265
|
+
register();
|
|
266
|
+
const hb = setInterval(heartbeat, 10000);
|
|
267
|
+
hb.unref();
|
|
268
|
+
console.log('[' + name + '] Listening for messages... (Ctrl+C to stop)');
|
|
269
|
+
setInterval(processMessages, 2000);
|
|
270
|
+
|
|
271
|
+
// Cleanup on exit
|
|
272
|
+
process.on('SIGINT', function() { console.log('\\n[' + name + '] Shutting down.'); process.exit(0); });
|
|
273
|
+
`;
|
|
274
|
+
|
|
275
|
+
fs.writeFileSync(scriptPath, script);
|
|
276
|
+
console.log(' [ok] Ollama agent script created: .agent-bridge/ollama-agent.js');
|
|
277
|
+
console.log('');
|
|
278
|
+
console.log(' Launch an Ollama agent with:');
|
|
279
|
+
console.log(' node .agent-bridge/ollama-agent.js <name> <model>');
|
|
280
|
+
console.log('');
|
|
281
|
+
console.log(' Examples:');
|
|
282
|
+
console.log(' node .agent-bridge/ollama-agent.js Ollama llama3');
|
|
283
|
+
console.log(' node .agent-bridge/ollama-agent.js Coder codellama');
|
|
284
|
+
console.log(' node .agent-bridge/ollama-agent.js Writer mistral');
|
|
285
|
+
}
|
|
286
|
+
|
|
150
287
|
function init() {
|
|
151
288
|
const cwd = process.cwd();
|
|
152
289
|
const serverPath = path.join(__dirname, 'server.js').replace(/\\/g, '/');
|
|
@@ -168,6 +305,18 @@ function init() {
|
|
|
168
305
|
targets = ['codex'];
|
|
169
306
|
} else if (flag === '--all') {
|
|
170
307
|
targets = ['claude', 'gemini', 'codex'];
|
|
308
|
+
} else if (flag === '--ollama') {
|
|
309
|
+
const ollama = detectOllama();
|
|
310
|
+
if (!ollama.installed) {
|
|
311
|
+
console.log(' Ollama not found. Install it from: https://ollama.com/download');
|
|
312
|
+
console.log(' After installing, run: ollama pull llama3');
|
|
313
|
+
console.log('');
|
|
314
|
+
} else {
|
|
315
|
+
console.log(' Ollama detected: ' + ollama.version);
|
|
316
|
+
setupOllama(serverPath, cwd);
|
|
317
|
+
}
|
|
318
|
+
targets = detectCLIs();
|
|
319
|
+
if (targets.length === 0) targets = ['claude'];
|
|
171
320
|
} else {
|
|
172
321
|
// Auto-detect
|
|
173
322
|
targets = detectCLIs();
|
package/dashboard.html
CHANGED
|
@@ -2611,6 +2611,13 @@
|
|
|
2611
2611
|
</div>
|
|
2612
2612
|
</div>
|
|
2613
2613
|
<div class="header-actions">
|
|
2614
|
+
<div style="position:relative;display:inline-block">
|
|
2615
|
+
<button class="notif-toggle" id="notif-bell" onclick="toggleNotifPanel()" title="Notifications" style="position:relative">🔔<span id="notif-badge" style="display:none;position:absolute;top:-4px;right:-4px;background:var(--red);color:#fff;font-size:8px;font-weight:700;min-width:14px;height:14px;border-radius:7px;text-align:center;line-height:14px;padding:0 3px">0</span></button>
|
|
2616
|
+
<div id="notif-panel" style="display:none;position:absolute;right:0;top:100%;margin-top:8px;background:var(--surface);border:1px solid var(--border-light);border-radius:12px;width:320px;max-height:400px;overflow-y:auto;z-index:300;box-shadow:var(--shadow-lg)">
|
|
2617
|
+
<div style="padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;font-size:13px;display:flex;justify-content:space-between;align-items:center">Notifications <button onclick="clearNotifications()" style="background:none;border:none;color:var(--text-muted);font-size:11px;cursor:pointer">Clear</button></div>
|
|
2618
|
+
<div id="notif-list" style="padding:4px 0"><div style="padding:16px;text-align:center;color:var(--text-muted);font-size:12px">No notifications yet</div></div>
|
|
2619
|
+
</div>
|
|
2620
|
+
</div>
|
|
2614
2621
|
<button class="notif-toggle" id="notif-toggle" onclick="toggleNotifications()" title="Browser notifications">🔔</button>
|
|
2615
2622
|
<button class="theme-toggle" id="theme-toggle" onclick="toggleTheme()" title="Toggle dark/light theme">🌙</button>
|
|
2616
2623
|
<button class="sound-toggle" id="sound-toggle" onclick="toggleSound()" title="Toggle notification sound">🔈</button>
|
|
@@ -2623,6 +2630,7 @@
|
|
|
2623
2630
|
<div style="padding:7px 12px;font-size:12px;cursor:pointer;transition:background 0.1s" onmouseover="this.style.background='var(--surface-3)'" onmouseout="this.style.background=''" onclick="exportShareableHTML();toggleExportMenu()">HTML (shareable)</div>
|
|
2624
2631
|
<div style="padding:7px 12px;font-size:12px;cursor:pointer;transition:background 0.1s" onmouseover="this.style.background='var(--surface-3)'" onmouseout="this.style.background=''" onclick="exportConversation();toggleExportMenu()">Markdown (.md)</div>
|
|
2625
2632
|
<div style="padding:7px 12px;font-size:12px;cursor:pointer;transition:background 0.1s" onmouseover="this.style.background='var(--surface-3)'" onmouseout="this.style.background=''" onclick="exportJSON();toggleExportMenu()">JSON (.json)</div>
|
|
2633
|
+
<div style="padding:7px 12px;font-size:12px;cursor:pointer;transition:background 0.1s;border-top:1px solid var(--border)" onmouseover="this.style.background='var(--surface-3)'" onmouseout="this.style.background=''" onclick="exportReplay();toggleExportMenu()">Animated Replay (.html)</div>
|
|
2626
2634
|
</div>
|
|
2627
2635
|
</div>
|
|
2628
2636
|
<button class="btn btn-danger" onclick="doReset()">Reset</button>
|
|
@@ -2744,6 +2752,7 @@
|
|
|
2744
2752
|
<div class="branch-tabs" id="branch-tabs"></div>
|
|
2745
2753
|
<div class="search-bar" id="search-bar">
|
|
2746
2754
|
<input class="search-input" id="search-input" placeholder="Search messages... ( / )" oninput="onSearch()">
|
|
2755
|
+
<button id="search-all-btn" onclick="toggleSearchAll()" title="Search across all projects" style="background:var(--surface-2);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:10px;cursor:pointer;color:var(--text-muted);white-space:nowrap;transition:all 0.2s">All Projects</button>
|
|
2747
2756
|
<span class="search-count" id="search-count"></span>
|
|
2748
2757
|
<button class="compact-toggle" id="compact-toggle" onclick="toggleCompactMode()" title="Toggle compact view">Compact</button>
|
|
2749
2758
|
</div>
|
|
@@ -2778,7 +2787,7 @@
|
|
|
2778
2787
|
</div>
|
|
2779
2788
|
</div>
|
|
2780
2789
|
<div class="app-footer">
|
|
2781
|
-
<span>Let Them Talk v3.
|
|
2790
|
+
<span>Let Them Talk v3.5.0</span>
|
|
2782
2791
|
</div>
|
|
2783
2792
|
<div class="profile-popup" id="profile-popup" onclick="event.stopPropagation()">
|
|
2784
2793
|
<div class="profile-popup-header">
|
|
@@ -3182,8 +3191,10 @@ function renderAgents(agents) {
|
|
|
3182
3191
|
}
|
|
3183
3192
|
|
|
3184
3193
|
function sendNudge(agentName) {
|
|
3194
|
+
var lockedProject = activeProject;
|
|
3195
|
+
var pq = lockedProject ? '?project=' + encodeURIComponent(lockedProject) : '';
|
|
3185
3196
|
var body = JSON.stringify({ to: agentName, content: 'Hey ' + agentName + ', the user is waiting for you. Please check for new messages and continue your work.' });
|
|
3186
|
-
lttFetch('/api/inject' +
|
|
3197
|
+
lttFetch('/api/inject' + pq, {
|
|
3187
3198
|
method: 'POST',
|
|
3188
3199
|
headers: { 'Content-Type': 'application/json' },
|
|
3189
3200
|
body: body
|
|
@@ -3240,8 +3251,11 @@ function doInject() {
|
|
|
3240
3251
|
var content = document.getElementById('inject-content').value.trim();
|
|
3241
3252
|
if (!target || !content) return;
|
|
3242
3253
|
|
|
3254
|
+
// Lock project context at send time — prevents race if user switches project mid-type
|
|
3255
|
+
var lockedProject = activeProject;
|
|
3256
|
+
var pq = lockedProject ? '?project=' + encodeURIComponent(lockedProject) : '';
|
|
3243
3257
|
var body = JSON.stringify({ to: target, content: content });
|
|
3244
|
-
lttFetch('/api/inject' +
|
|
3258
|
+
lttFetch('/api/inject' + pq, {
|
|
3245
3259
|
method: 'POST',
|
|
3246
3260
|
headers: { 'Content-Type': 'application/json' },
|
|
3247
3261
|
body: body
|
|
@@ -3558,6 +3572,10 @@ var searchQuery = '';
|
|
|
3558
3572
|
|
|
3559
3573
|
function onSearch() {
|
|
3560
3574
|
searchQuery = document.getElementById('search-input').value.toLowerCase().trim();
|
|
3575
|
+
if (searchAllMode && searchQuery.length >= 2) {
|
|
3576
|
+
searchAllProjects(searchQuery);
|
|
3577
|
+
return;
|
|
3578
|
+
}
|
|
3561
3579
|
lastMessageCount = 0;
|
|
3562
3580
|
renderMessages(cachedHistory);
|
|
3563
3581
|
}
|
|
@@ -4281,6 +4299,7 @@ function fetchStats() {
|
|
|
4281
4299
|
lttFetch('/api/stats' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
4282
4300
|
renderStats(data);
|
|
4283
4301
|
}).catch(function(e) { console.error('Stats fetch failed:', e); });
|
|
4302
|
+
fetchScores();
|
|
4284
4303
|
}
|
|
4285
4304
|
|
|
4286
4305
|
function renderStats(data) {
|
|
@@ -4356,6 +4375,11 @@ function renderStats(data) {
|
|
|
4356
4375
|
}
|
|
4357
4376
|
html += '</div></div>';
|
|
4358
4377
|
|
|
4378
|
+
html += '<div style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:16px;margin-top:16px">' +
|
|
4379
|
+
'<h3 style="font-size:14px;font-weight:700;margin-bottom:12px;color:var(--accent)">Agent Leaderboard</h3>' +
|
|
4380
|
+
'<div id="scores-area"><div style="color:var(--text-muted);font-size:12px">Loading scores...</div></div>' +
|
|
4381
|
+
'</div>';
|
|
4382
|
+
|
|
4359
4383
|
el.innerHTML = html;
|
|
4360
4384
|
}
|
|
4361
4385
|
|
|
@@ -4686,6 +4710,163 @@ function switchBranch(name) {
|
|
|
4686
4710
|
poll();
|
|
4687
4711
|
}
|
|
4688
4712
|
|
|
4713
|
+
// ==================== v3.5: NOTIFICATIONS PANEL ====================
|
|
4714
|
+
|
|
4715
|
+
var notifData = [];
|
|
4716
|
+
var notifSeen = 0;
|
|
4717
|
+
|
|
4718
|
+
function toggleNotifPanel() {
|
|
4719
|
+
var panel = document.getElementById('notif-panel');
|
|
4720
|
+
var isOpen = panel.style.display !== 'none';
|
|
4721
|
+
panel.style.display = isOpen ? 'none' : 'block';
|
|
4722
|
+
if (!isOpen) {
|
|
4723
|
+
notifSeen = notifData.length;
|
|
4724
|
+
updateNotifBadge();
|
|
4725
|
+
fetchNotifications();
|
|
4726
|
+
}
|
|
4727
|
+
}
|
|
4728
|
+
|
|
4729
|
+
function fetchNotifications() {
|
|
4730
|
+
var pq = projectParam();
|
|
4731
|
+
lttFetch('/api/notifications' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
4732
|
+
notifData = Array.isArray(data) ? data : [];
|
|
4733
|
+
renderNotifList();
|
|
4734
|
+
updateNotifBadge();
|
|
4735
|
+
}).catch(function() {});
|
|
4736
|
+
}
|
|
4737
|
+
|
|
4738
|
+
function renderNotifList() {
|
|
4739
|
+
var el = document.getElementById('notif-list');
|
|
4740
|
+
if (!notifData.length) { el.innerHTML = '<div style="padding:16px;text-align:center;color:var(--text-muted);font-size:12px">No notifications yet</div>'; return; }
|
|
4741
|
+
var html = '';
|
|
4742
|
+
for (var i = notifData.length - 1; i >= 0; i--) {
|
|
4743
|
+
var n = notifData[i];
|
|
4744
|
+
var icon = n.type === 'online' ? '🟢' : n.type === 'offline' ? '🔴' : n.type === 'listening' ? '🔊' : '🔕';
|
|
4745
|
+
var time = new Date(n.timestamp).toLocaleTimeString();
|
|
4746
|
+
html += '<div style="padding:8px 16px;border-bottom:1px solid var(--border);font-size:12px;display:flex;gap:8px;align-items:center">' +
|
|
4747
|
+
'<span>' + icon + '</span>' +
|
|
4748
|
+
'<div style="flex:1"><div style="color:var(--text)">' + escapeHtml(n.message) + '</div><div style="color:var(--text-muted);font-size:10px">' + time + '</div></div>' +
|
|
4749
|
+
'</div>';
|
|
4750
|
+
}
|
|
4751
|
+
el.innerHTML = html;
|
|
4752
|
+
}
|
|
4753
|
+
|
|
4754
|
+
function updateNotifBadge() {
|
|
4755
|
+
var badge = document.getElementById('notif-badge');
|
|
4756
|
+
var unseen = notifData.length - notifSeen;
|
|
4757
|
+
if (unseen > 0) { badge.textContent = unseen; badge.style.display = 'block'; }
|
|
4758
|
+
else { badge.style.display = 'none'; }
|
|
4759
|
+
}
|
|
4760
|
+
|
|
4761
|
+
function clearNotifications() {
|
|
4762
|
+
notifData = [];
|
|
4763
|
+
notifSeen = 0;
|
|
4764
|
+
renderNotifList();
|
|
4765
|
+
updateNotifBadge();
|
|
4766
|
+
}
|
|
4767
|
+
|
|
4768
|
+
// Close notif panel on outside click
|
|
4769
|
+
document.addEventListener('click', function(e) {
|
|
4770
|
+
var panel = document.getElementById('notif-panel');
|
|
4771
|
+
var bell = document.getElementById('notif-bell');
|
|
4772
|
+
if (panel && panel.style.display !== 'none' && !panel.contains(e.target) && e.target !== bell && !bell.contains(e.target)) {
|
|
4773
|
+
panel.style.display = 'none';
|
|
4774
|
+
}
|
|
4775
|
+
});
|
|
4776
|
+
|
|
4777
|
+
// ==================== v3.5: CROSS-PROJECT SEARCH ====================
|
|
4778
|
+
|
|
4779
|
+
var searchAllMode = false;
|
|
4780
|
+
|
|
4781
|
+
function toggleSearchAll() {
|
|
4782
|
+
searchAllMode = !searchAllMode;
|
|
4783
|
+
var btn = document.getElementById('search-all-btn');
|
|
4784
|
+
btn.style.background = searchAllMode ? 'var(--accent-dim)' : 'var(--surface-2)';
|
|
4785
|
+
btn.style.color = searchAllMode ? 'var(--accent)' : 'var(--text-muted)';
|
|
4786
|
+
btn.style.borderColor = searchAllMode ? 'var(--accent)' : 'var(--border)';
|
|
4787
|
+
if (searchAllMode) {
|
|
4788
|
+
document.getElementById('search-input').placeholder = 'Search ALL projects...';
|
|
4789
|
+
} else {
|
|
4790
|
+
document.getElementById('search-input').placeholder = 'Search messages... ( / )';
|
|
4791
|
+
}
|
|
4792
|
+
onSearch();
|
|
4793
|
+
}
|
|
4794
|
+
|
|
4795
|
+
function searchAllProjects(query) {
|
|
4796
|
+
if (!query || query.length < 2) return;
|
|
4797
|
+
var countEl = document.getElementById('search-count');
|
|
4798
|
+
countEl.textContent = 'Searching...';
|
|
4799
|
+
lttFetch('/api/search-all?q=' + encodeURIComponent(query) + '&limit=30').then(function(r) { return r.json(); }).then(function(data) {
|
|
4800
|
+
if (data.error) { countEl.textContent = data.error; return; }
|
|
4801
|
+
countEl.textContent = data.total + ' results across ' + data.results.length + ' projects';
|
|
4802
|
+
// Render results in the messages area
|
|
4803
|
+
var area = document.getElementById('messages');
|
|
4804
|
+
var html = '';
|
|
4805
|
+
for (var i = 0; i < data.results.length; i++) {
|
|
4806
|
+
var proj = data.results[i];
|
|
4807
|
+
html += '<div class="date-sep">' + escapeHtml(proj.project) + ' (' + proj.messages.length + ')</div>';
|
|
4808
|
+
for (var j = 0; j < proj.messages.length; j++) {
|
|
4809
|
+
var m = proj.messages[j];
|
|
4810
|
+
var time = new Date(m.timestamp).toLocaleString();
|
|
4811
|
+
html += '<div class="message" style="opacity:0.85">' +
|
|
4812
|
+
'<div class="msg-body"><div class="msg-header"><span class="msg-from" style="color:var(--accent)">' + escapeHtml(m.from) + '</span>' +
|
|
4813
|
+
'<span class="msg-arrow">→</span><span class="msg-to">' + escapeHtml(m.to || 'all') + '</span>' +
|
|
4814
|
+
'<span class="msg-time">' + time + '</span></div>' +
|
|
4815
|
+
'<div class="msg-content">' + escapeHtml(m.content.substring(0, 300)) + (m.content.length > 300 ? '...' : '') + '</div></div></div>';
|
|
4816
|
+
}
|
|
4817
|
+
}
|
|
4818
|
+
if (!html) html = '<div class="empty-state"><div class="empty-text">No results found</div></div>';
|
|
4819
|
+
area.innerHTML = html;
|
|
4820
|
+
}).catch(function() { countEl.textContent = 'Search failed'; });
|
|
4821
|
+
}
|
|
4822
|
+
|
|
4823
|
+
// ==================== v3.5: REPLAY EXPORT ====================
|
|
4824
|
+
|
|
4825
|
+
function exportReplay() {
|
|
4826
|
+
var pq = projectParam();
|
|
4827
|
+
window.location.href = '/api/export-replay' + (pq || '?') + (_lttToken ? '&token=' + encodeURIComponent(_lttToken) : '');
|
|
4828
|
+
}
|
|
4829
|
+
|
|
4830
|
+
// ==================== v3.5: PERFORMANCE SCORES ====================
|
|
4831
|
+
|
|
4832
|
+
function fetchScores() {
|
|
4833
|
+
var pq = projectParam();
|
|
4834
|
+
lttFetch('/api/scores' + pq).then(function(r) { return r.json(); }).then(function(data) {
|
|
4835
|
+
renderScores(data);
|
|
4836
|
+
}).catch(function() {});
|
|
4837
|
+
}
|
|
4838
|
+
|
|
4839
|
+
function renderScores(data) {
|
|
4840
|
+
if (!data || !data.agents) return;
|
|
4841
|
+
var el = document.getElementById('scores-area');
|
|
4842
|
+
if (!el) return;
|
|
4843
|
+
var agents = data.agents;
|
|
4844
|
+
var names = Object.keys(agents).sort(function(a, b) { return agents[b].score - agents[a].score; });
|
|
4845
|
+
if (!names.length) { el.innerHTML = '<div style="color:var(--text-muted);font-size:12px;padding:16px;text-align:center">No agent data yet</div>'; return; }
|
|
4846
|
+
|
|
4847
|
+
var html = '<div style="display:grid;gap:8px">';
|
|
4848
|
+
for (var i = 0; i < names.length; i++) {
|
|
4849
|
+
var n = names[i];
|
|
4850
|
+
var a = agents[n];
|
|
4851
|
+
var medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : '#' + (i + 1);
|
|
4852
|
+
var scoreColor = a.score >= 80 ? 'var(--green)' : a.score >= 60 ? 'var(--accent)' : a.score >= 40 ? 'var(--orange)' : 'var(--red)';
|
|
4853
|
+
html += '<div style="background:var(--surface-2);border:1px solid var(--border);border-radius:10px;padding:12px 16px;display:flex;align-items:center;gap:12px">' +
|
|
4854
|
+
'<div style="font-size:18px;width:28px;text-align:center">' + medal + '</div>' +
|
|
4855
|
+
'<div style="flex:1;min-width:0"><div style="font-weight:600;font-size:13px">' + escapeHtml(n) + '</div>' +
|
|
4856
|
+
'<div style="display:flex;gap:12px;margin-top:4px;font-size:10px;color:var(--text-muted)">' +
|
|
4857
|
+
'<span>Resp: ' + a.responsiveness + '</span>' +
|
|
4858
|
+
'<span>Act: ' + a.activity + '</span>' +
|
|
4859
|
+
'<span>Rel: ' + a.reliability + '</span>' +
|
|
4860
|
+
'<span>Collab: ' + a.collaboration + '</span>' +
|
|
4861
|
+
'</div>' +
|
|
4862
|
+
'</div>' +
|
|
4863
|
+
'<div style="font-size:22px;font-weight:700;color:' + scoreColor + '">' + a.score + '</div>' +
|
|
4864
|
+
'</div>';
|
|
4865
|
+
}
|
|
4866
|
+
html += '</div>';
|
|
4867
|
+
el.innerHTML = html;
|
|
4868
|
+
}
|
|
4869
|
+
|
|
4689
4870
|
// ==================== POLLING ====================
|
|
4690
4871
|
|
|
4691
4872
|
function poll() {
|
|
@@ -4737,6 +4918,7 @@ function poll() {
|
|
|
4737
4918
|
renderBookmarksSidebar();
|
|
4738
4919
|
fetchActivity();
|
|
4739
4920
|
fetchBranches();
|
|
4921
|
+
fetchNotifications();
|
|
4740
4922
|
updateTypingIndicator(cachedAgents);
|
|
4741
4923
|
if (activeView === 'tasks') fetchTasks();
|
|
4742
4924
|
if (activeView === 'workspaces') fetchWorkspaces();
|
package/dashboard.js
CHANGED
|
@@ -138,8 +138,15 @@ function readJson(file) {
|
|
|
138
138
|
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
function isPidAlive(pid) {
|
|
142
|
-
try {
|
|
141
|
+
function isPidAlive(pid, lastActivity) {
|
|
142
|
+
try {
|
|
143
|
+
process.kill(pid, 0);
|
|
144
|
+
if (lastActivity) {
|
|
145
|
+
const stale = Date.now() - new Date(lastActivity).getTime();
|
|
146
|
+
if (stale > 30000) return false; // 30s = 3 missed heartbeats
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
} catch { return false; }
|
|
143
150
|
}
|
|
144
151
|
|
|
145
152
|
// --- Default avatar helpers ---
|
|
@@ -207,7 +214,7 @@ function apiAgents(query) {
|
|
|
207
214
|
}
|
|
208
215
|
|
|
209
216
|
for (const [name, info] of Object.entries(agents)) {
|
|
210
|
-
const alive = isPidAlive(info.pid);
|
|
217
|
+
const alive = isPidAlive(info.pid, info.last_activity);
|
|
211
218
|
const lastActivity = info.last_activity || info.timestamp;
|
|
212
219
|
const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
213
220
|
const profile = profiles[name] || {};
|
|
@@ -240,9 +247,9 @@ function apiStatus(query) {
|
|
|
240
247
|
history.forEach(m => { if (m.thread_id) threads.add(m.thread_id); });
|
|
241
248
|
|
|
242
249
|
const agentEntries = Object.entries(agents);
|
|
243
|
-
const aliveCount = agentEntries.filter(([, a]) => isPidAlive(a.pid)).length;
|
|
250
|
+
const aliveCount = agentEntries.filter(([, a]) => isPidAlive(a.pid, a.last_activity)).length;
|
|
244
251
|
const sleepingCount = agentEntries.filter(([, a]) => {
|
|
245
|
-
if (!isPidAlive(a.pid)) return false;
|
|
252
|
+
if (!isPidAlive(a.pid, a.last_activity)) return false;
|
|
246
253
|
const lastActivity = a.last_activity || a.timestamp;
|
|
247
254
|
const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
248
255
|
return idleSeconds > 60;
|
|
@@ -325,10 +332,252 @@ function apiStats(query) {
|
|
|
325
332
|
};
|
|
326
333
|
}
|
|
327
334
|
|
|
335
|
+
// --- v3.4: Notification Tracking ---
|
|
336
|
+
let notificationHistory = [];
|
|
337
|
+
let prevAgentState = {};
|
|
338
|
+
|
|
339
|
+
function generateNotifications(currentAgents) {
|
|
340
|
+
const crypto = require('crypto');
|
|
341
|
+
const now = new Date().toISOString();
|
|
342
|
+
|
|
343
|
+
for (const [name, agent] of Object.entries(currentAgents)) {
|
|
344
|
+
const prev = prevAgentState[name];
|
|
345
|
+
const isAlive = agent.pid ? isPidAlive(agent.pid, agent.last_activity) : false;
|
|
346
|
+
const isListening = !!agent.listening;
|
|
347
|
+
|
|
348
|
+
if (prev) {
|
|
349
|
+
if (!prev.alive && isAlive) {
|
|
350
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_online', agent: name, message: `${name} came online`, timestamp: now });
|
|
351
|
+
}
|
|
352
|
+
if (prev.alive && !isAlive) {
|
|
353
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_offline', agent: name, message: `${name} went offline`, timestamp: now });
|
|
354
|
+
}
|
|
355
|
+
if (!prev.listening && isListening) {
|
|
356
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_listening', agent: name, message: `${name} started listening`, timestamp: now });
|
|
357
|
+
}
|
|
358
|
+
if (prev.listening && !isListening) {
|
|
359
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_busy', agent: name, message: `${name} stopped listening`, timestamp: now });
|
|
360
|
+
}
|
|
361
|
+
} else if (isAlive) {
|
|
362
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_online', agent: name, message: `${name} came online`, timestamp: now });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
prevAgentState[name] = { alive: isAlive, listening: isListening };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Trim to max 50
|
|
369
|
+
if (notificationHistory.length > 50) {
|
|
370
|
+
notificationHistory = notificationHistory.slice(-50);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function apiNotifications() {
|
|
375
|
+
return notificationHistory;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// --- v3.4: Performance Scoring ---
|
|
379
|
+
function apiScores(query) {
|
|
380
|
+
const projectPath = query.get('project') || null;
|
|
381
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
382
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
383
|
+
|
|
384
|
+
const perAgent = {};
|
|
385
|
+
const totalMessages = history.length;
|
|
386
|
+
const allAgentNames = new Set();
|
|
387
|
+
|
|
388
|
+
// Gather per-agent data
|
|
389
|
+
for (let i = 0; i < history.length; i++) {
|
|
390
|
+
const m = history[i];
|
|
391
|
+
const from = m.from || 'unknown';
|
|
392
|
+
allAgentNames.add(from);
|
|
393
|
+
if (m.to) allAgentNames.add(m.to);
|
|
394
|
+
if (!perAgent[from]) perAgent[from] = { messages: 0, responseTimes: [], peers: new Set() };
|
|
395
|
+
perAgent[from].messages++;
|
|
396
|
+
if (m.to) perAgent[from].peers.add(m.to);
|
|
397
|
+
|
|
398
|
+
if (m.reply_to) {
|
|
399
|
+
for (let j = i - 1; j >= Math.max(0, i - 50); j--) {
|
|
400
|
+
if (history[j].id === m.reply_to) {
|
|
401
|
+
const delta = new Date(m.timestamp).getTime() - new Date(history[j].timestamp).getTime();
|
|
402
|
+
if (delta > 0 && delta < 3600000) perAgent[from].responseTimes.push(delta / 1000);
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const totalAgents = allAgentNames.size;
|
|
410
|
+
const maxMessages = Math.max(1, ...Object.values(perAgent).map(d => d.messages));
|
|
411
|
+
|
|
412
|
+
const result = {};
|
|
413
|
+
const scores = [];
|
|
414
|
+
|
|
415
|
+
for (const [name, data] of Object.entries(perAgent)) {
|
|
416
|
+
const avgResponseSec = data.responseTimes.length
|
|
417
|
+
? data.responseTimes.reduce((a, b) => a + b, 0) / data.responseTimes.length
|
|
418
|
+
: Infinity;
|
|
419
|
+
|
|
420
|
+
// Responsiveness (30 pts)
|
|
421
|
+
let responsiveness;
|
|
422
|
+
if (avgResponseSec < 10) responsiveness = 30;
|
|
423
|
+
else if (avgResponseSec < 30) responsiveness = 25;
|
|
424
|
+
else if (avgResponseSec < 60) responsiveness = 20;
|
|
425
|
+
else if (avgResponseSec < 120) responsiveness = 15;
|
|
426
|
+
else responsiveness = 10;
|
|
427
|
+
|
|
428
|
+
// Activity (30 pts) — linear scale relative to top agent
|
|
429
|
+
const activity = Math.round((data.messages / maxMessages) * 30);
|
|
430
|
+
|
|
431
|
+
// Reliability (20 pts) — uptime based on agent registration
|
|
432
|
+
let reliability = 10;
|
|
433
|
+
const agentInfo = agents[name];
|
|
434
|
+
if (agentInfo) {
|
|
435
|
+
const isAlive = agentInfo.pid ? isPidAlive(agentInfo.pid, agentInfo.last_activity) : false;
|
|
436
|
+
const registered = new Date(agentInfo.registered_at || agentInfo.last_activity).getTime();
|
|
437
|
+
const totalTime = Date.now() - registered;
|
|
438
|
+
if (totalTime > 0 && isAlive) {
|
|
439
|
+
const lastAct = new Date(agentInfo.last_activity).getTime();
|
|
440
|
+
const activeTime = lastAct - registered;
|
|
441
|
+
const uptime = Math.min(1, activeTime / totalTime);
|
|
442
|
+
if (uptime > 0.95) reliability = 20;
|
|
443
|
+
else if (uptime > 0.80) reliability = 15;
|
|
444
|
+
else if (uptime > 0.50) reliability = 10;
|
|
445
|
+
else reliability = 5;
|
|
446
|
+
} else if (!isAlive) {
|
|
447
|
+
reliability = 5;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Collaboration (20 pts)
|
|
452
|
+
const collaboration = totalAgents > 1
|
|
453
|
+
? Math.round((data.peers.size / (totalAgents - 1)) * 20)
|
|
454
|
+
: 20;
|
|
455
|
+
|
|
456
|
+
const score = responsiveness + activity + reliability + collaboration;
|
|
457
|
+
result[name] = { score, responsiveness, activity, reliability, collaboration };
|
|
458
|
+
scores.push({ name, score });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Add ranks
|
|
462
|
+
scores.sort((a, b) => b.score - a.score);
|
|
463
|
+
scores.forEach((s, i) => { result[s.name].rank = i + 1; });
|
|
464
|
+
|
|
465
|
+
return { agents: result };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// --- v3.4: Cross-Project Search ---
|
|
469
|
+
function apiSearchAll(query) {
|
|
470
|
+
const q = (query.get('q') || '').toLowerCase();
|
|
471
|
+
const limit = Math.min(parseInt(query.get('limit') || '50', 10), 200);
|
|
472
|
+
if (!q) return { error: 'Missing "q" parameter' };
|
|
473
|
+
|
|
474
|
+
const projects = getProjects();
|
|
475
|
+
// Add default project
|
|
476
|
+
const allProjects = [{ name: path.basename(process.cwd()), path: null }];
|
|
477
|
+
for (const p of projects) allProjects.push(p);
|
|
478
|
+
|
|
479
|
+
const results = [];
|
|
480
|
+
let total = 0;
|
|
481
|
+
|
|
482
|
+
for (const proj of allProjects) {
|
|
483
|
+
const history = readJsonl(filePath('history.jsonl', proj.path));
|
|
484
|
+
const matches = [];
|
|
485
|
+
for (const m of history) {
|
|
486
|
+
if (matches.length >= limit) break;
|
|
487
|
+
const content = (m.content || '').toLowerCase();
|
|
488
|
+
const from = (m.from || '').toLowerCase();
|
|
489
|
+
const to = (m.to || '').toLowerCase();
|
|
490
|
+
if (content.includes(q) || from.includes(q) || to.includes(q)) {
|
|
491
|
+
matches.push({ id: m.id, from: m.from, to: m.to, content: m.content, timestamp: m.timestamp });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (matches.length > 0) {
|
|
495
|
+
results.push({ project: proj.name, path: proj.path || process.cwd(), messages: matches });
|
|
496
|
+
total += matches.length;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return { results, total };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// --- v3.4: Replay Export ---
|
|
504
|
+
function apiExportReplay(query) {
|
|
505
|
+
const projectPath = query.get('project') || null;
|
|
506
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
507
|
+
const profiles = readJson(filePath('profiles.json', projectPath));
|
|
508
|
+
|
|
509
|
+
const colors = ['#58a6ff','#3fb950','#d29922','#bc8cff','#f778ba','#ff7b72','#79c0ff','#7ee787'];
|
|
510
|
+
const agentColors = {};
|
|
511
|
+
let colorIdx = 0;
|
|
512
|
+
for (const m of history) {
|
|
513
|
+
if (!agentColors[m.from]) agentColors[m.from] = colors[colorIdx++ % colors.length];
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const messagesJson = JSON.stringify(history.map(m => ({
|
|
517
|
+
from: m.from, to: m.to, content: m.content, timestamp: m.timestamp, color: agentColors[m.from] || '#58a6ff'
|
|
518
|
+
})));
|
|
519
|
+
|
|
520
|
+
return `<!DOCTYPE html>
|
|
521
|
+
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
522
|
+
<title>Let Them Talk — Replay</title>
|
|
523
|
+
<style>
|
|
524
|
+
:root{--bg:#0d1117;--surface:#161b22;--surface-2:#21262d;--border:#30363d;--text:#e6edf3;--dim:#8b949e}
|
|
525
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
526
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);line-height:1.6}
|
|
527
|
+
.header{background:var(--surface);border-bottom:1px solid var(--border);padding:12px 20px;display:flex;align-items:center;justify-content:space-between}
|
|
528
|
+
.title{font-size:16px;font-weight:700;color:var(--text)}
|
|
529
|
+
.controls{display:flex;gap:8px;align-items:center}
|
|
530
|
+
.controls button{background:var(--surface-2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 14px;cursor:pointer;font-size:13px}
|
|
531
|
+
.controls button:hover{background:var(--border)}
|
|
532
|
+
.controls select{background:var(--surface-2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:13px}
|
|
533
|
+
.messages{max-width:800px;margin:20px auto;padding:0 16px}
|
|
534
|
+
.msg{opacity:0;transform:translateY(8px);transition:opacity 0.3s,transform 0.3s;margin-bottom:12px;padding:10px 14px;background:var(--surface);border:1px solid var(--border);border-radius:8px}
|
|
535
|
+
.msg.visible{opacity:1;transform:translateY(0)}
|
|
536
|
+
.msg-header{display:flex;gap:8px;align-items:baseline;margin-bottom:4px;font-size:13px}
|
|
537
|
+
.msg-from{font-weight:700}
|
|
538
|
+
.msg-to{color:var(--dim)}
|
|
539
|
+
.msg-time{color:var(--dim);margin-left:auto;font-size:11px}
|
|
540
|
+
.msg-content{font-size:14px;white-space:pre-wrap;word-break:break-word}
|
|
541
|
+
.msg-content code{background:var(--surface-2);padding:1px 5px;border-radius:3px;font-size:0.9em}
|
|
542
|
+
.msg-content strong{font-weight:700}
|
|
543
|
+
.progress{font-size:12px;color:var(--dim)}
|
|
544
|
+
</style></head><body>
|
|
545
|
+
<div class="header">
|
|
546
|
+
<span class="title">Let Them Talk — Replay</span>
|
|
547
|
+
<div class="controls">
|
|
548
|
+
<button id="btn" onclick="toggle()">Pause</button>
|
|
549
|
+
<label><span style="color:var(--dim);font-size:12px">Speed:</span>
|
|
550
|
+
<select id="speed" onchange="setSpeed(this.value)">
|
|
551
|
+
<option value="2000">Slow</option><option value="1000" selected>Normal</option><option value="500">Fast</option><option value="200">Very Fast</option>
|
|
552
|
+
</select></label>
|
|
553
|
+
<span class="progress" id="progress">0 / 0</span>
|
|
554
|
+
</div></div>
|
|
555
|
+
<div class="messages" id="messages"></div>
|
|
556
|
+
<script>
|
|
557
|
+
var msgs=${messagesJson};
|
|
558
|
+
var idx=0,playing=true,timer=null,speed=1000;
|
|
559
|
+
function md(s){return s.replace(/\`\`\`[\\s\\S]*?\`\`\`/g,function(m){return '<pre><code>'+m.slice(3,-3).replace(/^\\w*\\n/,'')+'</code></pre>'}).replace(/\`([^\`]+)\`/g,'<code>$1</code>').replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>').replace(/^### (.+)$/gm,'<h4 style="margin:8px 0 4px;font-size:14px">$1</h4>').replace(/^## (.+)$/gm,'<h3 style="margin:8px 0 4px;font-size:15px">$1</h3>').replace(/^# (.+)$/gm,'<h2 style="margin:8px 0 4px;font-size:16px">$1</h2>')}
|
|
560
|
+
function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
|
561
|
+
function showNext(){if(idx>=msgs.length){playing=false;document.getElementById('btn').textContent='Done';return}
|
|
562
|
+
var m=msgs[idx],el=document.createElement('div');el.className='msg';
|
|
563
|
+
var t=new Date(m.timestamp);var time=t.toLocaleTimeString();
|
|
564
|
+
el.innerHTML='<div class="msg-header"><span class="msg-from" style="color:'+m.color+'">'+esc(m.from)+'</span><span class="msg-to">→ '+esc(m.to||'all')+'</span><span class="msg-time">'+time+'</span></div><div class="msg-content">'+md(esc(m.content))+'</div>';
|
|
565
|
+
document.getElementById('messages').appendChild(el);
|
|
566
|
+
requestAnimationFrame(function(){el.classList.add('visible')});
|
|
567
|
+
el.scrollIntoView({behavior:'smooth',block:'end'});
|
|
568
|
+
idx++;document.getElementById('progress').textContent=idx+' / '+msgs.length;
|
|
569
|
+
if(playing)timer=setTimeout(showNext,speed)}
|
|
570
|
+
function toggle(){if(idx>=msgs.length){idx=0;document.getElementById('messages').innerHTML='';playing=true;document.getElementById('btn').textContent='Pause';showNext();return}
|
|
571
|
+
playing=!playing;document.getElementById('btn').textContent=playing?'Pause':'Play';if(playing)showNext();else clearTimeout(timer)}
|
|
572
|
+
function setSpeed(v){speed=parseInt(v)}
|
|
573
|
+
showNext();
|
|
574
|
+
</script></body></html>`;
|
|
575
|
+
}
|
|
576
|
+
|
|
328
577
|
function apiReset(query) {
|
|
329
578
|
const projectPath = query.get('project') || null;
|
|
330
579
|
const dataDir = resolveDataDir(projectPath);
|
|
331
|
-
const fixedFiles = ['messages.jsonl', 'history.jsonl', 'agents.json', 'acks.json', 'tasks.json', 'profiles.json', 'workflows.json', 'branches.json', 'read_receipts.json', 'permissions.json'];
|
|
580
|
+
const fixedFiles = ['messages.jsonl', 'history.jsonl', 'agents.json', 'acks.json', 'tasks.json', 'profiles.json', 'workflows.json', 'branches.json', 'read_receipts.json', 'permissions.json', 'config.json'];
|
|
332
581
|
for (const f of fixedFiles) {
|
|
333
582
|
const p = path.join(dataDir, f);
|
|
334
583
|
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
@@ -1397,6 +1646,31 @@ const server = http.createServer(async (req, res) => {
|
|
|
1397
1646
|
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1398
1647
|
res.end(JSON.stringify(result));
|
|
1399
1648
|
}
|
|
1649
|
+
// --- v3.4: Notifications ---
|
|
1650
|
+
else if (url.pathname === '/api/notifications' && req.method === 'GET') {
|
|
1651
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1652
|
+
res.end(JSON.stringify(apiNotifications()));
|
|
1653
|
+
}
|
|
1654
|
+
// --- v3.4: Performance Scores ---
|
|
1655
|
+
else if (url.pathname === '/api/scores' && req.method === 'GET') {
|
|
1656
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1657
|
+
res.end(JSON.stringify(apiScores(url.searchParams)));
|
|
1658
|
+
}
|
|
1659
|
+
// --- v3.4: Cross-Project Search ---
|
|
1660
|
+
else if (url.pathname === '/api/search-all' && req.method === 'GET') {
|
|
1661
|
+
const result = apiSearchAll(url.searchParams);
|
|
1662
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1663
|
+
res.end(JSON.stringify(result));
|
|
1664
|
+
}
|
|
1665
|
+
// --- v3.4: Replay Export ---
|
|
1666
|
+
else if (url.pathname === '/api/export-replay' && req.method === 'GET') {
|
|
1667
|
+
const html = apiExportReplay(url.searchParams);
|
|
1668
|
+
res.writeHead(200, {
|
|
1669
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1670
|
+
'Content-Disposition': 'attachment; filename="replay-' + new Date().toISOString().slice(0, 10) + '.html"',
|
|
1671
|
+
});
|
|
1672
|
+
res.end(html);
|
|
1673
|
+
}
|
|
1400
1674
|
// Server-Sent Events endpoint for real-time updates
|
|
1401
1675
|
else if (url.pathname === '/api/events' && req.method === 'GET') {
|
|
1402
1676
|
if (sseClients.size >= 100) {
|
|
@@ -1429,6 +1703,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1429
1703
|
const sseClients = new Set();
|
|
1430
1704
|
|
|
1431
1705
|
function sseNotifyAll() {
|
|
1706
|
+
// Generate notifications from agent state changes
|
|
1707
|
+
try {
|
|
1708
|
+
const agents = readJson(filePath('agents.json'));
|
|
1709
|
+
generateNotifications(agents);
|
|
1710
|
+
} catch {}
|
|
1711
|
+
|
|
1432
1712
|
for (const res of sseClients) {
|
|
1433
1713
|
try {
|
|
1434
1714
|
res.write(`data: update\n\n`);
|
|
@@ -1472,7 +1752,7 @@ server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
|
|
|
1472
1752
|
const dataDir = resolveDataDir();
|
|
1473
1753
|
const lanIP = getLanIP();
|
|
1474
1754
|
console.log('');
|
|
1475
|
-
console.log(' Let Them Talk - Agent Bridge Dashboard v3.
|
|
1755
|
+
console.log(' Let Them Talk - Agent Bridge Dashboard v3.5.0');
|
|
1476
1756
|
console.log(' ============================================');
|
|
1477
1757
|
console.log(' Dashboard: http://localhost:' + PORT);
|
|
1478
1758
|
if (LAN_MODE && lanIP) {
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -27,6 +27,28 @@ let lastReadOffset = 0; // byte offset into messages.jsonl for efficient polling
|
|
|
27
27
|
let heartbeatInterval = null; // heartbeat timer reference
|
|
28
28
|
let messageSeq = 0; // monotonic sequence counter for message ordering
|
|
29
29
|
let currentBranch = 'main'; // which branch this agent is on
|
|
30
|
+
let lastSentAt = 0; // timestamp of last sent message (for group cooldown)
|
|
31
|
+
|
|
32
|
+
// --- Group conversation mode ---
|
|
33
|
+
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
34
|
+
|
|
35
|
+
function getConfig() {
|
|
36
|
+
if (!fs.existsSync(CONFIG_FILE)) return {};
|
|
37
|
+
try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function saveConfig(config) {
|
|
41
|
+
ensureDataDir();
|
|
42
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isGroupMode() {
|
|
46
|
+
return getConfig().conversation_mode === 'group';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getGroupCooldown() {
|
|
50
|
+
return getConfig().group_cooldown || 3000; // default 3s
|
|
51
|
+
}
|
|
30
52
|
|
|
31
53
|
// Rate limiting — prevent broadcast storms and message flooding
|
|
32
54
|
const rateLimitWindow = 60000; // 1 minute window
|
|
@@ -86,6 +108,22 @@ function readJsonl(file) {
|
|
|
86
108
|
}).filter(Boolean);
|
|
87
109
|
}
|
|
88
110
|
|
|
111
|
+
// File-based lock for agents.json (prevents registration race conditions)
|
|
112
|
+
const AGENTS_LOCK = AGENTS_FILE + '.lock';
|
|
113
|
+
function lockAgentsFile() {
|
|
114
|
+
const maxWait = 5000; const start = Date.now();
|
|
115
|
+
while (Date.now() - start < maxWait) {
|
|
116
|
+
try { fs.writeFileSync(AGENTS_LOCK, String(process.pid), { flag: 'wx' }); return true; }
|
|
117
|
+
catch { /* lock exists, wait */ }
|
|
118
|
+
const wait = Date.now(); while (Date.now() - wait < 50) {} // busy-wait 50ms
|
|
119
|
+
}
|
|
120
|
+
// Force-break stale lock after timeout
|
|
121
|
+
try { fs.unlinkSync(AGENTS_LOCK); } catch {}
|
|
122
|
+
try { fs.writeFileSync(AGENTS_LOCK, String(process.pid), { flag: 'wx' }); return true; } catch {}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
function unlockAgentsFile() { try { fs.unlinkSync(AGENTS_LOCK); } catch {} }
|
|
126
|
+
|
|
89
127
|
function getAgents() {
|
|
90
128
|
if (!fs.existsSync(AGENTS_FILE)) return {};
|
|
91
129
|
try {
|
|
@@ -108,9 +146,15 @@ function getAcks() {
|
|
|
108
146
|
}
|
|
109
147
|
}
|
|
110
148
|
|
|
111
|
-
function isPidAlive(pid) {
|
|
149
|
+
function isPidAlive(pid, lastActivity) {
|
|
112
150
|
try {
|
|
113
151
|
process.kill(pid, 0);
|
|
152
|
+
// On Windows, PIDs get reused. If the heartbeat stopped (no activity for 30s = 3 missed
|
|
153
|
+
// heartbeats), treat as stale even if PID exists (it's likely a different process now)
|
|
154
|
+
if (lastActivity) {
|
|
155
|
+
const stale = Date.now() - new Date(lastActivity).getTime();
|
|
156
|
+
if (stale > 30000) return false; // 30s = 3 missed heartbeats
|
|
157
|
+
}
|
|
114
158
|
return true;
|
|
115
159
|
} catch {
|
|
116
160
|
return false;
|
|
@@ -185,7 +229,7 @@ function buildMessageResponse(msg, consumedIds) {
|
|
|
185
229
|
|
|
186
230
|
// Count online agents
|
|
187
231
|
const agents = getAgents();
|
|
188
|
-
const agentsOnline = Object.entries(agents).filter(([, info]) => isPidAlive(info.pid)).length;
|
|
232
|
+
const agentsOnline = Object.entries(agents).filter(([, info]) => isPidAlive(info.pid, info.last_activity)).length;
|
|
189
233
|
|
|
190
234
|
// Count total messages for context window management
|
|
191
235
|
let totalMessages = 0;
|
|
@@ -422,30 +466,32 @@ function getHistoryFile(branch) {
|
|
|
422
466
|
function toolRegister(name, provider = null) {
|
|
423
467
|
ensureDataDir();
|
|
424
468
|
sanitizeName(name);
|
|
469
|
+
lockAgentsFile();
|
|
425
470
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
471
|
+
try {
|
|
472
|
+
const agents = getAgents();
|
|
473
|
+
if (agents[name] && agents[name].pid !== process.pid && isPidAlive(agents[name].pid, agents[name].last_activity)) {
|
|
474
|
+
return { error: `Agent "${name}" is already registered by a live process. Choose a different name.` };
|
|
475
|
+
}
|
|
430
476
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
477
|
+
// If name was previously registered by a dead process, verify token to prevent impersonation
|
|
478
|
+
if (agents[name] && agents[name].token && !isPidAlive(agents[name].pid, agents[name].last_activity)) {
|
|
479
|
+
// Dead agent — only allow re-registration from the same process (same token)
|
|
480
|
+
if (registeredToken && registeredToken !== agents[name].token) {
|
|
481
|
+
return { error: `Agent "${name}" was previously registered by another process. Choose a different name.` };
|
|
482
|
+
}
|
|
436
483
|
}
|
|
437
|
-
}
|
|
438
484
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
485
|
+
// Clean up old registration if re-registering with a different name
|
|
486
|
+
if (registeredName && registeredName !== name && agents[registeredName] && agents[registeredName].pid === process.pid) {
|
|
487
|
+
delete agents[registeredName];
|
|
488
|
+
}
|
|
443
489
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
490
|
+
const now = new Date().toISOString();
|
|
491
|
+
const token = (agents[name] && agents[name].token) || generateToken();
|
|
492
|
+
agents[name] = { pid: process.pid, timestamp: now, last_activity: now, provider: provider || 'unknown', branch: currentBranch, token, started_at: now };
|
|
493
|
+
saveAgents(agents);
|
|
494
|
+
registeredName = name;
|
|
449
495
|
registeredToken = token;
|
|
450
496
|
|
|
451
497
|
// Auto-create profile if not exists
|
|
@@ -468,7 +514,10 @@ function toolRegister(name, provider = null) {
|
|
|
468
514
|
}, 10000);
|
|
469
515
|
heartbeatInterval.unref(); // Don't prevent process exit
|
|
470
516
|
|
|
471
|
-
|
|
517
|
+
return { success: true, message: `Registered as Agent ${name} (PID ${process.pid})` };
|
|
518
|
+
} finally {
|
|
519
|
+
unlockAgentsFile();
|
|
520
|
+
}
|
|
472
521
|
}
|
|
473
522
|
|
|
474
523
|
// Update last_activity timestamp for this agent
|
|
@@ -500,7 +549,7 @@ function toolListAgents() {
|
|
|
500
549
|
const profiles = getProfiles();
|
|
501
550
|
const result = {};
|
|
502
551
|
for (const [name, info] of Object.entries(agents)) {
|
|
503
|
-
const alive = isPidAlive(info.pid);
|
|
552
|
+
const alive = isPidAlive(info.pid, info.last_activity);
|
|
504
553
|
const lastActivity = info.last_activity || info.timestamp;
|
|
505
554
|
const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
506
555
|
const profile = profiles[name] || {};
|
|
@@ -523,7 +572,7 @@ function toolListAgents() {
|
|
|
523
572
|
return { agents: result };
|
|
524
573
|
}
|
|
525
574
|
|
|
526
|
-
function toolSendMessage(content, to = null, reply_to = null) {
|
|
575
|
+
async function toolSendMessage(content, to = null, reply_to = null) {
|
|
527
576
|
if (!registeredName) {
|
|
528
577
|
return { error: 'You must call register() first' };
|
|
529
578
|
}
|
|
@@ -531,6 +580,15 @@ function toolSendMessage(content, to = null, reply_to = null) {
|
|
|
531
580
|
const rateErr = checkRateLimit();
|
|
532
581
|
if (rateErr) return rateErr;
|
|
533
582
|
|
|
583
|
+
// Group mode cooldown — prevent agents from responding too fast
|
|
584
|
+
if (isGroupMode()) {
|
|
585
|
+
const cooldown = getGroupCooldown();
|
|
586
|
+
const elapsed = Date.now() - lastSentAt;
|
|
587
|
+
if (elapsed < cooldown) {
|
|
588
|
+
await sleep(cooldown - elapsed);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
534
592
|
const agents = getAgents();
|
|
535
593
|
const otherAgents = Object.keys(agents).filter(n => n !== registeredName);
|
|
536
594
|
|
|
@@ -564,7 +622,7 @@ function toolSendMessage(content, to = null, reply_to = null) {
|
|
|
564
622
|
if (sizeErr) return sizeErr;
|
|
565
623
|
|
|
566
624
|
// Check if recipient is alive — warn if dead
|
|
567
|
-
const recipientAlive = isPidAlive(agents[to].pid);
|
|
625
|
+
const recipientAlive = isPidAlive(agents[to].pid, agents[to].last_activity);
|
|
568
626
|
|
|
569
627
|
// Resolve threading
|
|
570
628
|
let thread_id = null;
|
|
@@ -594,6 +652,19 @@ function toolSendMessage(content, to = null, reply_to = null) {
|
|
|
594
652
|
fs.appendFileSync(getMessagesFile(currentBranch), JSON.stringify(msg) + '\n');
|
|
595
653
|
fs.appendFileSync(getHistoryFile(currentBranch), JSON.stringify(msg) + '\n');
|
|
596
654
|
touchActivity();
|
|
655
|
+
lastSentAt = Date.now();
|
|
656
|
+
|
|
657
|
+
// In group mode, auto-broadcast: also write to all other agents' queues
|
|
658
|
+
// Skip if this message is already a response to a broadcast (prevents cascade)
|
|
659
|
+
if (isGroupMode() && !reply_to && !msg.broadcast) {
|
|
660
|
+
const otherRecipients = Object.keys(getAgents()).filter(n => n !== registeredName && n !== to);
|
|
661
|
+
for (const other of otherRecipients) {
|
|
662
|
+
if (!canSendTo(registeredName, other)) continue; // respect permissions
|
|
663
|
+
const broadcastMsg = { ...msg, id: generateId(), to: other, broadcast: true, original_to: to };
|
|
664
|
+
fs.appendFileSync(getMessagesFile(currentBranch), JSON.stringify(broadcastMsg) + '\n');
|
|
665
|
+
fs.appendFileSync(getHistoryFile(currentBranch), JSON.stringify(broadcastMsg) + '\n');
|
|
666
|
+
}
|
|
667
|
+
}
|
|
597
668
|
|
|
598
669
|
const result = { success: true, messageId: msg.id, from: msg.from, to: msg.to };
|
|
599
670
|
if (currentBranch !== 'main') result.branch = currentBranch;
|
|
@@ -641,6 +712,7 @@ function toolBroadcast(content) {
|
|
|
641
712
|
ids.push({ to, messageId: msg.id });
|
|
642
713
|
}
|
|
643
714
|
touchActivity();
|
|
715
|
+
lastSentAt = Date.now();
|
|
644
716
|
|
|
645
717
|
const result = { success: true, sent_to: ids, recipient_count: ids.length };
|
|
646
718
|
if (skipped.length > 0) result.skipped = skipped;
|
|
@@ -868,6 +940,97 @@ async function toolListenCodex(from = null) {
|
|
|
868
940
|
};
|
|
869
941
|
}
|
|
870
942
|
|
|
943
|
+
// --- Group conversation tools ---
|
|
944
|
+
|
|
945
|
+
function toolSetConversationMode(mode) {
|
|
946
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
947
|
+
if (!['group', 'direct'].includes(mode)) return { error: 'Mode must be "group" or "direct"' };
|
|
948
|
+
const config = getConfig();
|
|
949
|
+
config.conversation_mode = mode;
|
|
950
|
+
if (mode === 'group' && !config.group_cooldown) config.group_cooldown = 3000;
|
|
951
|
+
saveConfig(config);
|
|
952
|
+
return {
|
|
953
|
+
success: true,
|
|
954
|
+
mode,
|
|
955
|
+
message: mode === 'group'
|
|
956
|
+
? 'Group mode enabled. Use listen_group() to receive batched messages. All messages are shared with everyone.'
|
|
957
|
+
: 'Direct mode enabled. Use listen() for point-to-point messaging.'
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async function toolListenGroup(timeout_seconds = 300) {
|
|
962
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
963
|
+
const timeoutMs = Math.min(Math.max(1, timeout_seconds || 300), 3600) * 1000;
|
|
964
|
+
|
|
965
|
+
setListening(true);
|
|
966
|
+
|
|
967
|
+
// Random stagger to prevent all agents from responding simultaneously (1-3s)
|
|
968
|
+
const stagger = 1000 + Math.random() * 2000;
|
|
969
|
+
await new Promise(r => setTimeout(r, stagger));
|
|
970
|
+
|
|
971
|
+
const deadline = Date.now() + timeoutMs;
|
|
972
|
+
const consumed = getConsumedIds(registeredName);
|
|
973
|
+
|
|
974
|
+
while (Date.now() < deadline) {
|
|
975
|
+
// Collect ALL unconsumed messages addressed to us or broadcast
|
|
976
|
+
const messages = readJsonl(getMessagesFile(currentBranch));
|
|
977
|
+
const batch = [];
|
|
978
|
+
for (const msg of messages) {
|
|
979
|
+
if (consumed.has(msg.id)) continue;
|
|
980
|
+
if (msg.to !== registeredName && msg.to !== '__all__') continue;
|
|
981
|
+
// Permission check
|
|
982
|
+
const perms = getPermissions();
|
|
983
|
+
if (perms[registeredName] && perms[registeredName].can_read) {
|
|
984
|
+
const allowed = perms[registeredName].can_read;
|
|
985
|
+
if (allowed !== '*' && Array.isArray(allowed) && !allowed.includes(msg.from) && !msg.system) continue;
|
|
986
|
+
}
|
|
987
|
+
batch.push(msg);
|
|
988
|
+
consumed.add(msg.id);
|
|
989
|
+
markAsRead(registeredName, msg.id);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (batch.length > 0) {
|
|
993
|
+
saveConsumedIds(registeredName, consumed);
|
|
994
|
+
touchActivity();
|
|
995
|
+
setListening(false);
|
|
996
|
+
|
|
997
|
+
// Get recent history for context
|
|
998
|
+
const history = readJsonl(getHistoryFile(currentBranch));
|
|
999
|
+
const recentHistory = history.slice(-20).map(m => ({
|
|
1000
|
+
from: m.from, to: m.to, content: m.content.substring(0, 500),
|
|
1001
|
+
timestamp: m.timestamp, id: m.id,
|
|
1002
|
+
}));
|
|
1003
|
+
|
|
1004
|
+
// Count agents and who hasn't spoken recently
|
|
1005
|
+
const agents = getAgents();
|
|
1006
|
+
const agentNames = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
|
|
1007
|
+
const recentSpeakers = new Set(history.slice(-10).map(m => m.from));
|
|
1008
|
+
const silent = agentNames.filter(n => !recentSpeakers.has(n) && n !== registeredName);
|
|
1009
|
+
|
|
1010
|
+
return {
|
|
1011
|
+
messages: batch.map(m => ({
|
|
1012
|
+
id: m.id, from: m.from, to: m.to, content: m.content,
|
|
1013
|
+
timestamp: m.timestamp,
|
|
1014
|
+
...(m.reply_to && { reply_to: m.reply_to }),
|
|
1015
|
+
...(m.thread_id && { thread_id: m.thread_id }),
|
|
1016
|
+
})),
|
|
1017
|
+
message_count: batch.length,
|
|
1018
|
+
context: recentHistory,
|
|
1019
|
+
agents_online: agentNames.length,
|
|
1020
|
+
agents_silent: silent,
|
|
1021
|
+
hint: silent.length > 0
|
|
1022
|
+
? `${silent.join(', ')} haven't spoken recently. Consider addressing them.`
|
|
1023
|
+
: 'All agents are active in the conversation.',
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
await adaptiveSleep(0);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
setListening(false);
|
|
1031
|
+
return { timeout: true, message: 'No messages received within timeout.', messages: [], message_count: 0 };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
871
1034
|
function toolGetHistory(limit = 50, thread_id = null) {
|
|
872
1035
|
limit = Math.min(Math.max(1, limit || 50), 500);
|
|
873
1036
|
let history = readJsonl(getHistoryFile(currentBranch));
|
|
@@ -911,6 +1074,11 @@ function toolHandoff(to, context) {
|
|
|
911
1074
|
const sizeErr = validateContentSize(context);
|
|
912
1075
|
if (sizeErr) return sizeErr;
|
|
913
1076
|
|
|
1077
|
+
// Permission check
|
|
1078
|
+
if (!canSendTo(registeredName, to)) {
|
|
1079
|
+
return { error: `Permission denied: you are not allowed to hand off to "${to}"` };
|
|
1080
|
+
}
|
|
1081
|
+
|
|
914
1082
|
const agents = getAgents();
|
|
915
1083
|
if (!agents[to]) {
|
|
916
1084
|
return { error: `Agent "${to}" is not registered` };
|
|
@@ -1170,7 +1338,7 @@ function toolReset() {
|
|
|
1170
1338
|
}
|
|
1171
1339
|
}
|
|
1172
1340
|
// Remove profiles, workflows, branches, permissions, read receipts
|
|
1173
|
-
for (const f of [PROFILES_FILE, WORKFLOWS_FILE, BRANCHES_FILE, PERMISSIONS_FILE, READ_RECEIPTS_FILE]) {
|
|
1341
|
+
for (const f of [PROFILES_FILE, WORKFLOWS_FILE, BRANCHES_FILE, PERMISSIONS_FILE, READ_RECEIPTS_FILE, CONFIG_FILE]) {
|
|
1174
1342
|
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
1175
1343
|
}
|
|
1176
1344
|
// Remove workspaces dir
|
|
@@ -1354,9 +1522,9 @@ function toolAdvanceWorkflow(workflowId, notes) {
|
|
|
1354
1522
|
nextStep.status = 'in_progress';
|
|
1355
1523
|
nextStep.started_at = new Date().toISOString();
|
|
1356
1524
|
|
|
1357
|
-
// Auto-handoff to next assignee
|
|
1525
|
+
// Auto-handoff to next assignee (respecting permissions)
|
|
1358
1526
|
const agents = getAgents();
|
|
1359
|
-
if (nextStep.assignee && agents[nextStep.assignee] && nextStep.assignee !== registeredName) {
|
|
1527
|
+
if (nextStep.assignee && agents[nextStep.assignee] && nextStep.assignee !== registeredName && canSendTo(registeredName, nextStep.assignee)) {
|
|
1360
1528
|
const handoffContent = `[Workflow "${wf.name}"] Step ${nextStep.id} assigned to you: ${nextStep.description}`;
|
|
1361
1529
|
messageSeq++;
|
|
1362
1530
|
const msg = { id: generateId(), seq: messageSeq, from: registeredName, to: nextStep.assignee, content: handoffContent, timestamp: new Date().toISOString(), type: 'handoff' };
|
|
@@ -1480,7 +1648,7 @@ function toolListBranches() {
|
|
|
1480
1648
|
// --- MCP Server setup ---
|
|
1481
1649
|
|
|
1482
1650
|
const server = new Server(
|
|
1483
|
-
{ name: 'agent-bridge', version: '3.
|
|
1651
|
+
{ name: 'agent-bridge', version: '3.5.0' },
|
|
1484
1652
|
{ capabilities: { tools: {} } }
|
|
1485
1653
|
);
|
|
1486
1654
|
|
|
@@ -1858,6 +2026,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1858
2026
|
properties: {},
|
|
1859
2027
|
},
|
|
1860
2028
|
},
|
|
2029
|
+
{
|
|
2030
|
+
name: 'set_conversation_mode',
|
|
2031
|
+
description: 'Switch between "group" (free multi-agent chat with auto-broadcast, cooldown, and batched delivery) and "direct" (default point-to-point messaging). Group mode lets all agents see all messages and collaborate freely.',
|
|
2032
|
+
inputSchema: {
|
|
2033
|
+
type: 'object',
|
|
2034
|
+
properties: {
|
|
2035
|
+
mode: { type: 'string', description: '"group" for free multi-agent chat, "direct" for point-to-point (default)', enum: ['group', 'direct'] },
|
|
2036
|
+
},
|
|
2037
|
+
required: ['mode'],
|
|
2038
|
+
},
|
|
2039
|
+
},
|
|
2040
|
+
{
|
|
2041
|
+
name: 'listen_group',
|
|
2042
|
+
description: 'Listen for messages in group conversation mode. Returns ALL unconsumed messages as a batch (not just one), plus recent conversation context and hints about which agents are silent. Includes a random stagger delay (1-3s) to prevent all agents from responding simultaneously. Use this instead of listen() when in group mode.',
|
|
2043
|
+
inputSchema: {
|
|
2044
|
+
type: 'object',
|
|
2045
|
+
properties: {
|
|
2046
|
+
timeout_seconds: { type: 'number', description: 'Max seconds to wait for messages (default 300)' },
|
|
2047
|
+
},
|
|
2048
|
+
},
|
|
2049
|
+
},
|
|
1861
2050
|
],
|
|
1862
2051
|
};
|
|
1863
2052
|
});
|
|
@@ -1876,7 +2065,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1876
2065
|
result = toolListAgents();
|
|
1877
2066
|
break;
|
|
1878
2067
|
case 'send_message':
|
|
1879
|
-
result = toolSendMessage(args.content, args?.to, args?.reply_to);
|
|
2068
|
+
result = await toolSendMessage(args.content, args?.to, args?.reply_to);
|
|
1880
2069
|
break;
|
|
1881
2070
|
case 'wait_for_reply':
|
|
1882
2071
|
result = await toolWaitForReply(args?.timeout_seconds, args?.from);
|
|
@@ -1950,6 +2139,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1950
2139
|
case 'list_branches':
|
|
1951
2140
|
result = toolListBranches();
|
|
1952
2141
|
break;
|
|
2142
|
+
case 'set_conversation_mode':
|
|
2143
|
+
result = toolSetConversationMode(args.mode);
|
|
2144
|
+
break;
|
|
2145
|
+
case 'listen_group':
|
|
2146
|
+
result = await toolListenGroup(args?.timeout_seconds);
|
|
2147
|
+
break;
|
|
1953
2148
|
default:
|
|
1954
2149
|
return {
|
|
1955
2150
|
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
@@ -1977,6 +2172,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1977
2172
|
|
|
1978
2173
|
// Clean up agent registration on exit for instant status updates
|
|
1979
2174
|
process.on('exit', () => {
|
|
2175
|
+
unlockAgentsFile(); // Clean up any held lock
|
|
1980
2176
|
if (registeredName) {
|
|
1981
2177
|
try {
|
|
1982
2178
|
const agents = getAgents();
|
|
@@ -1994,7 +2190,7 @@ async function main() {
|
|
|
1994
2190
|
ensureDataDir();
|
|
1995
2191
|
const transport = new StdioServerTransport();
|
|
1996
2192
|
await server.connect(transport);
|
|
1997
|
-
console.error('Agent Bridge MCP server v3.
|
|
2193
|
+
console.error('Agent Bridge MCP server v3.5.0 running (29 tools)');
|
|
1998
2194
|
}
|
|
1999
2195
|
|
|
2000
2196
|
main().catch(console.error);
|