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 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.4.4
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
- - **27 MCP tools** — messaging, tasks, workflows, profiles, workspaces, branching
88
- - **Premium dashboard** — glassmorphism UI, Inter font, gradient accents, SSE real-time (~200ms)
89
- - **Stats & analytics** — per-agent message counts, response times, hourly activity charts, velocity
90
- - **Conversation templates** — 4 built-in multi-agent workflows (Code Review, Debug Squad, Feature Dev, Research & Write)
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
- - **Compact view** — dense message toggle for power users, persists to localStorage
97
- - **Multi-format export** — HTML, Markdown, and JSON export
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.4.4
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 --template T Initialize with a team template (pair, team, review, debate)
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">&#x1f514;<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">&#x1f514;</button>
2615
2622
  <button class="theme-toggle" id="theme-toggle" onclick="toggleTheme()" title="Toggle dark/light theme">&#x1f319;</button>
2616
2623
  <button class="sound-toggle" id="sound-toggle" onclick="toggleSound()" title="Toggle notification sound">&#x1f508;</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.4.4</span>
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' + projectParam(), {
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' + projectParam(), {
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' ? '&#x1f7e2;' : n.type === 'offline' ? '&#x1f534;' : n.type === 'listening' ? '&#x1f50a;' : '&#x1f515;';
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">&rarr;</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 ? '&#x1f947;' : i === 1 ? '&#x1f948;' : i === 2 ? '&#x1f949;' : '#' + (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 { process.kill(pid, 0); return true; } catch { return false; }
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
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.4.4');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "let-them-talk",
3
- "version": "3.4.4",
3
+ "version": "3.5.0",
4
4
  "description": "MCP message broker + web dashboard for inter-agent communication. Let AI CLI agents talk to each other.",
5
5
  "main": "server.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -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
- const agents = getAgents();
427
- if (agents[name] && agents[name].pid !== process.pid && isPidAlive(agents[name].pid)) {
428
- return { error: `Agent "${name}" is already registered by a live process. Choose a different name.` };
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
- // If name was previously registered by a dead process, verify token to prevent impersonation
432
- if (agents[name] && agents[name].token && !isPidAlive(agents[name].pid)) {
433
- // Dead agent — only allow re-registration from the same process (same token)
434
- if (registeredToken && registeredToken !== agents[name].token) {
435
- return { error: `Agent "${name}" was previously registered by another process. Choose a different name.` };
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
- // Clean up old registration if re-registering with a different name
440
- if (registeredName && registeredName !== name && agents[registeredName] && agents[registeredName].pid === process.pid) {
441
- delete agents[registeredName];
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
- const now = new Date().toISOString();
445
- const token = (agents[name] && agents[name].token) || generateToken();
446
- agents[name] = { pid: process.pid, timestamp: now, last_activity: now, provider: provider || 'unknown', branch: currentBranch, token };
447
- saveAgents(agents);
448
- registeredName = name;
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
- return { success: true, message: `Registered as Agent ${name} (PID ${process.pid})` };
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.4.4' },
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.4.4 running (27 tools)');
2193
+ console.error('Agent Bridge MCP server v3.5.0 running (29 tools)');
1998
2194
  }
1999
2195
 
2000
2196
  main().catch(console.error);