let-them-talk 4.0.2 → 4.3.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/dashboard.js CHANGED
@@ -186,7 +186,22 @@ function apiHistory(query) {
186
186
  const histFile = branch && branch !== 'main'
187
187
  ? filePath(`branch-${branch}-history.jsonl`, projectPath)
188
188
  : filePath('history.jsonl', projectPath);
189
- const history = readJsonl(histFile);
189
+ let history = readJsonl(histFile);
190
+
191
+ // Merge channel-specific history files
192
+ const dataDir = resolveDataDir(projectPath);
193
+ try {
194
+ const files = fs.readdirSync(dataDir);
195
+ for (const f of files) {
196
+ if (f.startsWith('channel-') && f.endsWith('-history.jsonl') && f !== 'channel-general-history.jsonl') {
197
+ const channelHistory = readJsonl(path.join(dataDir, f));
198
+ history = history.concat(channelHistory);
199
+ }
200
+ }
201
+ } catch {}
202
+ // Sort merged messages by timestamp
203
+ history.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
204
+
190
205
  const acks = readJson(filePath('acks.json', projectPath));
191
206
  const limit = parseInt(query.get('limit') || '500', 10);
192
207
  const threadId = query.get('thread_id');
@@ -200,6 +215,29 @@ function apiHistory(query) {
200
215
  return messages;
201
216
  }
202
217
 
218
+ function apiChannels(query) {
219
+ const projectPath = query.get('project') || null;
220
+ const channelsFile = filePath('channels.json', projectPath);
221
+ const channels = readJson(channelsFile);
222
+ if (!channels) return { general: { description: 'General channel', members: ['*'], message_count: 0 } };
223
+ const dataDir = resolveDataDir(projectPath);
224
+ const result = {};
225
+ for (const [name, ch] of Object.entries(channels)) {
226
+ let msgCount = 0;
227
+ const msgFile = name === 'general'
228
+ ? filePath('history.jsonl', projectPath)
229
+ : path.join(dataDir, 'channel-' + name + '-history.jsonl');
230
+ try {
231
+ if (fs.existsSync(msgFile)) {
232
+ const content = fs.readFileSync(msgFile, 'utf8').trim();
233
+ if (content) msgCount = content.split('\n').length;
234
+ }
235
+ } catch {}
236
+ result[name] = { description: ch.description || '', members: ch.members, message_count: msgCount };
237
+ }
238
+ return result;
239
+ }
240
+
203
241
  function apiAgents(query) {
204
242
  const projectPath = query.get('project') || null;
205
243
  const agents = readJson(filePath('agents.json', projectPath));
@@ -236,6 +274,14 @@ function apiAgents(query) {
236
274
  bio: profile.bio || '',
237
275
  appearance: profile.appearance || {},
238
276
  };
277
+ // Include workspace status for agent intent board
278
+ try {
279
+ const wsPath = path.join(resolveDataDir(projectPath), 'workspaces', name + '.json');
280
+ if (fs.existsSync(wsPath)) {
281
+ const ws = JSON.parse(fs.readFileSync(wsPath, 'utf8'));
282
+ if (ws._status) result[name].current_status = ws._status;
283
+ }
284
+ } catch {}
239
285
  }
240
286
  return result;
241
287
  }
@@ -1388,11 +1434,35 @@ const server = http.createServer(async (req, res) => {
1388
1434
  if (libPath.includes('..') || libPath.includes('\\')) {
1389
1435
  res.writeHead(400); res.end('Bad path'); return;
1390
1436
  }
1391
- // Search multiple node_modules locations (handles npx, local dev, monorepo)
1437
+ // Search multiple node_modules locations (handles npx, local dev, monorepo, global)
1392
1438
  const searchPaths = [
1393
- path.join(__dirname, 'node_modules', libPath), // inside agent-bridge (npx installs deps here)
1439
+ path.join(__dirname, 'node_modules', libPath), // inside package (nested deps)
1394
1440
  path.join(__dirname, '..', 'node_modules', libPath), // repo root (local dev)
1441
+ path.join(__dirname, '..', libPath), // npx sibling packages (three/ is next to let-them-talk/)
1395
1442
  ];
1443
+ // Also try require.resolve for robust npm path resolution (works with hoisted deps, npx cache, etc.)
1444
+ // Note: use require.resolve(pkg) not require.resolve(pkg/package.json) — modern packages
1445
+ // with "exports" fields block resolving package.json directly (ERR_PACKAGE_PATH_NOT_EXPORTED)
1446
+ try {
1447
+ const parts = libPath.split('/');
1448
+ const pkgName = parts[0];
1449
+ const subPath = parts.slice(1).join('/');
1450
+ // Try resolving the package's main entry, then navigate to subPath
1451
+ const resolved = require.resolve(pkgName);
1452
+ const pkgDir = path.dirname(resolved);
1453
+ // Walk up from the resolved entry to the package root (handle nested build/ dirs)
1454
+ let pkgRoot = pkgDir;
1455
+ while (pkgRoot !== path.dirname(pkgRoot)) {
1456
+ if (fs.existsSync(path.join(pkgRoot, 'package.json'))) {
1457
+ try {
1458
+ const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8'));
1459
+ if (pkg.name === pkgName) break;
1460
+ } catch {}
1461
+ }
1462
+ pkgRoot = path.dirname(pkgRoot);
1463
+ }
1464
+ searchPaths.push(path.join(pkgRoot, subPath));
1465
+ } catch {}
1396
1466
  const filePath = searchPaths.find(p => fs.existsSync(p));
1397
1467
  if (filePath) {
1398
1468
  const ext = path.extname(filePath);
@@ -1468,6 +1538,16 @@ const server = http.createServer(async (req, res) => {
1468
1538
  res.writeHead(200, { 'Content-Type': 'application/json' });
1469
1539
  res.end(JSON.stringify(apiAgents(url.searchParams)));
1470
1540
  }
1541
+ else if (url.pathname === '/api/channels' && req.method === 'GET') {
1542
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1543
+ res.end(JSON.stringify(apiChannels(url.searchParams)));
1544
+ }
1545
+ else if (url.pathname === '/api/decisions' && req.method === 'GET') {
1546
+ const projectPath = url.searchParams.get('project') || null;
1547
+ const decisions = readJson(filePath('decisions.json', projectPath));
1548
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1549
+ res.end(JSON.stringify(decisions || []));
1550
+ }
1471
1551
  else if (url.pathname === '/api/agents' && req.method === 'DELETE') {
1472
1552
  const body = await parseBody(req);
1473
1553
  if (!body.name) {
@@ -1504,6 +1584,133 @@ const server = http.createServer(async (req, res) => {
1504
1584
  res.end(JSON.stringify({ success: true, removed: agentName }));
1505
1585
  });
1506
1586
  }
1587
+ // Respawn prompt generator — creates copy-paste prompt to revive a dead agent
1588
+ else if (url.pathname.startsWith('/api/agents/') && url.pathname.endsWith('/respawn-prompt') && req.method === 'GET') {
1589
+ const agentName = decodeURIComponent(url.pathname.split('/')[3]);
1590
+ // Validate agent name (prevent path traversal)
1591
+ if (!agentName || /[^a-zA-Z0-9_-]/.test(agentName) || agentName.length > 20) {
1592
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1593
+ res.end(JSON.stringify({ error: 'Invalid agent name' }));
1594
+ return;
1595
+ }
1596
+ const projectPath = url.searchParams.get('project') || null;
1597
+ const dataDir = resolveDataDir(projectPath);
1598
+ const agents = readJson(filePath('agents.json', projectPath));
1599
+ const profiles = readJson(filePath('profiles.json', projectPath));
1600
+ const tasks = readJson(filePath('tasks.json', projectPath));
1601
+ const config = readJson(filePath('config.json', projectPath));
1602
+
1603
+ if (!agents[agentName]) {
1604
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1605
+ res.end(JSON.stringify({ error: 'Agent not found: ' + agentName }));
1606
+ return;
1607
+ }
1608
+
1609
+ // Gather recovery snapshot if exists
1610
+ const recoveryFile = path.join(dataDir, 'recovery-' + agentName + '.json');
1611
+ const recovery = fs.existsSync(recoveryFile) ? readJson(recoveryFile) : null;
1612
+
1613
+ // Gather profile
1614
+ const profile = profiles[agentName] || {};
1615
+
1616
+ // Gather active tasks assigned to this agent
1617
+ const taskList = Array.isArray(tasks) ? tasks : [];
1618
+ const activeTasks = taskList.filter(t => t.assignee === agentName && (t.status === 'in_progress' || t.status === 'pending'));
1619
+ const completedTasks = taskList.filter(t => t.assignee === agentName && t.status === 'done').slice(-5);
1620
+
1621
+ // Gather recent history context (last 15 messages)
1622
+ const history = readJsonl(filePath('history.jsonl', projectPath));
1623
+ const recentHistory = history.slice(-15).map(m => `[${m.from}→${m.to}]: ${(m.content || '').substring(0, 150)}`).join('\n');
1624
+
1625
+ // Gather who's online
1626
+ const onlineAgents = Object.entries(agents)
1627
+ .filter(([n, a]) => isPidAlive(a.pid, a.last_activity) && n !== agentName)
1628
+ .map(([n]) => n);
1629
+
1630
+ // Gather workspace status
1631
+ let workspaceStatus = '';
1632
+ try {
1633
+ const wsPath = path.join(dataDir, 'workspaces', agentName + '.json');
1634
+ if (fs.existsSync(wsPath)) {
1635
+ const ws = JSON.parse(fs.readFileSync(wsPath, 'utf8'));
1636
+ if (ws._status) workspaceStatus = ws._status;
1637
+ }
1638
+ } catch {}
1639
+
1640
+ // Build the respawn prompt
1641
+ const mode = config.conversation_mode || 'group';
1642
+ let prompt = `You are resuming as agent "${agentName}" in a multi-agent team using Let Them Talk (MCP agent bridge).\n\n`;
1643
+
1644
+ if (profile.role) prompt += `**Your role:** ${profile.role}\n`;
1645
+ if (profile.bio) prompt += `**Your bio:** ${profile.bio}\n`;
1646
+ prompt += '\n';
1647
+
1648
+ prompt += `**Conversation mode:** ${mode}\n`;
1649
+ prompt += `**Agents currently online:** ${onlineAgents.length > 0 ? onlineAgents.join(', ') : 'none'}\n\n`;
1650
+
1651
+ if (activeTasks.length > 0) {
1652
+ prompt += `**Your active tasks:**\n`;
1653
+ for (const t of activeTasks) {
1654
+ prompt += `- [${t.status}] ${t.title}${t.description ? ' — ' + t.description.substring(0, 200) : ''}\n`;
1655
+ }
1656
+ prompt += '\n';
1657
+ }
1658
+
1659
+ if (completedTasks.length > 0) {
1660
+ prompt += `**Tasks you completed before disconnect:**\n`;
1661
+ for (const t of completedTasks) {
1662
+ prompt += `- ${t.title}\n`;
1663
+ }
1664
+ prompt += '\n';
1665
+ }
1666
+
1667
+ if (recovery) {
1668
+ if (recovery.locked_files && recovery.locked_files.length > 0) {
1669
+ prompt += `**Files you had locked:** ${recovery.locked_files.join(', ')} — unlock these or continue editing them.\n\n`;
1670
+ }
1671
+ if (recovery.channels && recovery.channels.length > 0) {
1672
+ prompt += `**Channels you were in:** ${recovery.channels.join(', ')}\n\n`;
1673
+ }
1674
+ if (recovery.decisions_made && recovery.decisions_made.length > 0) {
1675
+ prompt += `**Decisions you made:**\n`;
1676
+ for (const d of recovery.decisions_made) {
1677
+ prompt += `- ${d.decision}${d.reasoning ? ' (reason: ' + d.reasoning + ')' : ''}\n`;
1678
+ }
1679
+ prompt += '\n';
1680
+ }
1681
+ if (recovery.last_messages_sent && recovery.last_messages_sent.length > 0) {
1682
+ prompt += `**Your last messages before disconnect:**\n`;
1683
+ for (const m of recovery.last_messages_sent) {
1684
+ prompt += `- [→${m.to}]: ${m.content}\n`;
1685
+ }
1686
+ prompt += '\n';
1687
+ }
1688
+ }
1689
+
1690
+ if (workspaceStatus) {
1691
+ prompt += `**Your last status:** ${workspaceStatus}\n\n`;
1692
+ }
1693
+
1694
+ prompt += `**Recent team conversation:**\n${recentHistory}\n\n`;
1695
+
1696
+ prompt += `**Instructions:**\n`;
1697
+ prompt += `1. Register as "${agentName}" using the register tool\n`;
1698
+ prompt += `2. Call get_briefing() for full project context\n`;
1699
+ prompt += `3. Call listen_group() to rejoin the conversation\n`;
1700
+ prompt += `4. Announce you're back and pick up your active tasks\n`;
1701
+ prompt += `5. Stay in listen_group() loop — never stop listening\n`;
1702
+
1703
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1704
+ res.end(JSON.stringify({
1705
+ agent: agentName,
1706
+ status: isPidAlive(agents[agentName].pid, agents[agentName].last_activity) ? 'alive' : 'dead',
1707
+ prompt,
1708
+ prompt_length: prompt.length,
1709
+ has_recovery: !!recovery,
1710
+ active_tasks: activeTasks.length,
1711
+ online_agents: onlineAgents,
1712
+ }));
1713
+ }
1507
1714
  else if (url.pathname === '/api/status' && req.method === 'GET') {
1508
1715
  res.writeHead(200, { 'Content-Type': 'application/json' });
1509
1716
  res.end(JSON.stringify(apiStatus(url.searchParams)));
@@ -1548,6 +1755,80 @@ const server = http.createServer(async (req, res) => {
1548
1755
  res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
1549
1756
  res.end(JSON.stringify(result));
1550
1757
  }
1758
+ else if (url.pathname === '/api/search' && req.method === 'GET') {
1759
+ const projectPath = url.searchParams.get('project') || null;
1760
+ const query = (url.searchParams.get('q') || '').trim();
1761
+ const from = url.searchParams.get('from') || null;
1762
+ const limit = Math.min(Math.max(1, parseInt(url.searchParams.get('limit') || '50', 10)), 100);
1763
+ if (query.length < 2) {
1764
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1765
+ res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
1766
+ return;
1767
+ }
1768
+ // Search general history + all channel histories
1769
+ let allHistory = readJsonl(filePath('history.jsonl', projectPath));
1770
+ const dataDir = resolveDataDir(projectPath);
1771
+ try {
1772
+ const files = fs.readdirSync(dataDir);
1773
+ for (const f of files) {
1774
+ if (f.startsWith('channel-') && f.endsWith('-history.jsonl')) {
1775
+ allHistory = allHistory.concat(readJsonl(path.join(dataDir, f)));
1776
+ }
1777
+ }
1778
+ } catch {}
1779
+ const queryLower = query.toLowerCase();
1780
+ const results = [];
1781
+ for (let i = allHistory.length - 1; i >= 0 && results.length < limit; i--) {
1782
+ const m = allHistory[i];
1783
+ if (from && m.from !== from) continue;
1784
+ if (m.content && m.content.toLowerCase().includes(queryLower)) {
1785
+ results.push({
1786
+ id: m.id, from: m.from, to: m.to,
1787
+ preview: m.content.substring(0, 200),
1788
+ timestamp: m.timestamp,
1789
+ ...(m.channel && { channel: m.channel }),
1790
+ });
1791
+ }
1792
+ }
1793
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1794
+ res.end(JSON.stringify({ query, results_count: results.length, results }));
1795
+ }
1796
+ else if (url.pathname === '/api/export-json' && req.method === 'GET') {
1797
+ const projectPath = url.searchParams.get('project') || null;
1798
+ const history = apiHistory(url.searchParams);
1799
+ const agents = apiAgents(url.searchParams);
1800
+ const decisions = readJson(filePath('decisions.json', projectPath)) || [];
1801
+ const tasks = readJson(filePath('tasks.json', projectPath)) || [];
1802
+ const channels = apiChannels(url.searchParams);
1803
+ const pkg = readJson(path.join(__dirname, 'package.json')) || {};
1804
+ const result = {
1805
+ export_version: 1,
1806
+ exported_at: new Date().toISOString(),
1807
+ project: projectPath || process.cwd(),
1808
+ version: pkg.version || 'unknown',
1809
+ summary: {
1810
+ message_count: history.length,
1811
+ agent_count: Object.keys(agents).length,
1812
+ decision_count: decisions.length,
1813
+ task_count: tasks.length,
1814
+ channel_count: Object.keys(channels).length,
1815
+ time_range: history.length > 0 ? {
1816
+ start: history[0].timestamp,
1817
+ end: history[history.length - 1].timestamp,
1818
+ } : null,
1819
+ },
1820
+ agents,
1821
+ channels,
1822
+ decisions,
1823
+ tasks,
1824
+ messages: history,
1825
+ };
1826
+ res.writeHead(200, {
1827
+ 'Content-Type': 'application/json; charset=utf-8',
1828
+ 'Content-Disposition': 'attachment; filename="conversation-' + new Date().toISOString().slice(0, 10) + '-full.json"',
1829
+ });
1830
+ res.end(JSON.stringify(result, null, 2));
1831
+ }
1551
1832
  else if (url.pathname === '/api/export' && req.method === 'GET') {
1552
1833
  const html = apiExportHtml(url.searchParams);
1553
1834
  res.writeHead(200, {
@@ -1560,6 +1841,39 @@ const server = http.createServer(async (req, res) => {
1560
1841
  res.writeHead(200, { 'Content-Type': 'application/json' });
1561
1842
  res.end(JSON.stringify(apiDiscover()));
1562
1843
  }
1844
+ // --- World Builder: load/save world layout ---
1845
+ else if (url.pathname === '/api/world-layout' && req.method === 'GET') {
1846
+ const projectPath = url.searchParams.get('project') || null;
1847
+ const worldFile = filePath('world-layout.json', projectPath);
1848
+ if (fs.existsSync(worldFile)) {
1849
+ try {
1850
+ const data = JSON.parse(fs.readFileSync(worldFile, 'utf8'));
1851
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1852
+ res.end(JSON.stringify(data));
1853
+ } catch {
1854
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1855
+ res.end('[]');
1856
+ }
1857
+ } else {
1858
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1859
+ res.end('[]');
1860
+ }
1861
+ }
1862
+ else if (url.pathname === '/api/world-save' && req.method === 'POST') {
1863
+ const body = await parseBody(req);
1864
+ const projectPath = url.searchParams.get('project') || null;
1865
+ const worldFile = filePath('world-layout.json', projectPath);
1866
+ if (!Array.isArray(body)) {
1867
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1868
+ res.end(JSON.stringify({ error: 'Expected array of placements' }));
1869
+ return;
1870
+ }
1871
+ // Limit to 1000 placements for safety
1872
+ const placements = body.slice(0, 1000);
1873
+ fs.writeFileSync(worldFile, JSON.stringify(placements, null, 2));
1874
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1875
+ res.end(JSON.stringify({ success: true, count: placements.length }));
1876
+ }
1563
1877
  // --- v3.0 API endpoints ---
1564
1878
  else if (url.pathname === '/api/profiles' && req.method === 'GET') {
1565
1879
  const projectPath = url.searchParams.get('project') || null;
@@ -1811,6 +2125,7 @@ const server = http.createServer(async (req, res) => {
1811
2125
  });
1812
2126
  res.end(html);
1813
2127
  }
2128
+ // (World Builder API endpoints are handled earlier in the route chain by Architect's implementation)
1814
2129
  // Server-Sent Events endpoint for real-time updates
1815
2130
  else if (url.pathname === '/api/events' && req.method === 'GET') {
1816
2131
  if (sseClients.size >= 100) {
package/office/agents.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { S } from './state.js';
2
- import { DESK_POSITIONS, SPAWN_POS } from './constants.js';
2
+ import { DESK_POSITIONS, SPAWN_POS, REST_AREA_POS, REST_AREA_ENTRANCE } from './constants.js';
3
3
  import { createCharacter } from './character.js';
4
4
  import { resolveAppearance } from './appearance.js';
5
5
  import { buildHair } from './hair.js';
6
- import { buildFaceSprite } from './face.js';
6
+ import { buildFaceSprite, setEmotion } from './face.js';
7
7
  import { buildOutfit, removeOutfit } from './outfits.js';
8
8
  import { getNavigationPath } from './navigation.js';
9
9
 
@@ -291,6 +291,36 @@ export function syncAgents() {
291
291
  }
292
292
  }
293
293
 
294
+ // --- Autonomous behaviors: sleeping → rest area, waking → back to desk ---
295
+ if (newState === 'sleeping' && oldState === 'active' && existing.location === 'desk' && existing.registered && !existing.dying) {
296
+ // Agent fell asleep — walk to rest area after a short delay
297
+ existing.location = 'walking';
298
+ (function(a) {
299
+ setTimeout(function() {
300
+ showBubble(a, 'Need a break...');
301
+ a.isSitting = false;
302
+ navigateTo(a, REST_AREA_ENTRANCE.x, REST_AREA_ENTRANCE.z, function() {
303
+ navigateTo(a, REST_AREA_POS.x, REST_AREA_POS.z, function() {
304
+ a.location = 'rest';
305
+ a.state = 'sleeping';
306
+ showBubble(a, 'zzz...');
307
+ });
308
+ });
309
+ }, 1000 + Math.random() * 2000);
310
+ })(existing);
311
+ }
312
+ if (newState === 'active' && (oldState === 'sleeping' || existing.location === 'rest') && existing.location !== 'desk' && existing.registered && !existing.dying) {
313
+ // Agent woke up — walk back to desk
314
+ existing.location = 'walking';
315
+ existing.state = 'active';
316
+ (function(a) {
317
+ showBubble(a, 'Back to work!');
318
+ navigateTo(a, a.deskPos.x, a.deskPos.z + 0.7, function() {
319
+ a.location = 'desk';
320
+ });
321
+ })(existing);
322
+ }
323
+
294
324
  existing.displayName = info.display_name || name;
295
325
  var wasListening = existing.isListening;
296
326
  existing.isListening = !!(info.is_listening);
@@ -313,11 +343,21 @@ export function syncAgents() {
313
343
  if (prevTask && prevTask.status !== 'done' && task.status === 'done') {
314
344
  existing.taskCelebration = 2;
315
345
  existing.celebrateTimer = 1.5;
346
+ setEmotion(existing, 'happy', 6);
347
+ }
348
+ // Blocked task → frustrated face
349
+ if (task.status === 'blocked' && (!prevTask || prevTask.status !== 'blocked')) {
350
+ setEmotion(existing, 'frustrated', 8);
316
351
  }
317
352
  } else {
318
353
  existing.currentTask = null;
319
354
  }
320
355
 
356
+ // Listening agents look focused
357
+ if (existing.isListening && !wasListening) {
358
+ setEmotion(existing, 'focused', 10);
359
+ }
360
+
321
361
  var newApp = info.appearance || {};
322
362
  if (JSON.stringify(newApp) !== JSON.stringify(existing.appearance)) {
323
363
  existing.appearance = newApp;
@@ -329,6 +369,66 @@ export function syncAgents() {
329
369
  }
330
370
  }
331
371
 
372
+ // --- Random social behavior: idle agents occasionally stretch or look around ---
373
+ // Limit concurrent social walks to prevent traffic jams (max 2 walking at once)
374
+ var walkingCount = 0;
375
+ for (var wn in S.agents3d) { if (S.agents3d[wn].location === 'walking') walkingCount++; }
376
+
377
+ for (var sn in S.agents3d) {
378
+ var sa = S.agents3d[sn];
379
+ if (!sa.registered || sa.state !== 'active' || sa.location !== 'desk' || sa.target) continue;
380
+ if (!sa._socialTimer) sa._socialTimer = 30 + Math.random() * 60;
381
+ sa._socialTimer -= 2; // syncAgents runs every ~2s
382
+ if (sa._socialTimer <= 0) {
383
+ sa._socialTimer = 40 + Math.random() * 80; // next social event in 40-120s
384
+ // Pick a random behavior: stretch, look around, or visit another agent
385
+ var roll = Math.random();
386
+ if (roll < 0.4) {
387
+ // Stretch at desk
388
+ sa.stretchTimer = 2;
389
+ } else if (roll < 0.7) {
390
+ // Look around curiously
391
+ sa.thinkTimer = 1.5;
392
+ } else if (walkingCount < 2) {
393
+ // Walk to a random nearby agent's desk to "chat" then return (max 2 concurrent)
394
+ var others = [];
395
+ for (var on in S.agents3d) {
396
+ if (on !== sn && S.agents3d[on].registered && S.agents3d[on].state === 'active' && S.agents3d[on].location === 'desk') {
397
+ others.push(S.agents3d[on]);
398
+ }
399
+ }
400
+ if (others.length > 0) {
401
+ var buddy = others[Math.floor(Math.random() * others.length)];
402
+ (function(a, b) {
403
+ a.location = 'walking';
404
+ a.isSitting = false;
405
+ showBubble(a, 'Hey ' + b.displayName + '!');
406
+ setEmotion(a, 'playful', 6);
407
+ var stopX = b.deskPos.x + 1.5;
408
+ var stopZ = b.deskPos.z + 0.7;
409
+ navigateTo(a, stopX, stopZ, function() {
410
+ // Face buddy
411
+ var dx = b.pos.x - a.pos.x;
412
+ var dz = b.pos.z - a.pos.z;
413
+ a.facingTarget = Math.atan2(dx, dz);
414
+ a.waveTimer = 0.8;
415
+ // Buddy turns toward visitor
416
+ b.facingTarget = Math.atan2(-dx, -dz);
417
+ setTimeout(function() {
418
+ showBubble(a, 'Back to it!');
419
+ navigateTo(a, a.deskPos.x, a.deskPos.z + 0.7, function() {
420
+ a.location = 'desk';
421
+ });
422
+ // Buddy turns back to desk
423
+ setTimeout(function() { b.facingTarget = Math.PI; }, 1500);
424
+ }, 3000 + Math.random() * 2000);
425
+ });
426
+ })(sa, buddy);
427
+ }
428
+ }
429
+ }
430
+ }
431
+
332
432
  for (var n in S.agents3d) {
333
433
  if (!window.cachedAgents[n]) {
334
434
  var deadAgent = S.agents3d[n];
@@ -345,8 +445,11 @@ export function processMessages() {
345
445
  var history = window.cachedHistory;
346
446
  if (!history || history.length === 0) return;
347
447
 
348
- var newMsgs = history.slice(S.lastProcessedMsg);
349
- S.lastProcessedMsg = history.length;
448
+ // Use window-level counter so it persists across 3D stop/start cycles (tab switches)
449
+ // This prevents message replay when user switches from Messages tab back to 3D Hub
450
+ if (typeof window._lastProcessedMsg === 'undefined') window._lastProcessedMsg = 0;
451
+ var newMsgs = history.slice(window._lastProcessedMsg);
452
+ window._lastProcessedMsg = history.length;
350
453
 
351
454
  for (var i = 0; i < newMsgs.length; i++) {
352
455
  var msg = newMsgs[i];
@@ -357,6 +460,37 @@ export function processMessages() {
357
460
  from.lastMessageTime = Date.now();
358
461
  flashDeskScreen(from.deskIdx);
359
462
 
463
+ // Instant preview bubble — show short text immediately before walk animation
464
+ // Gives users instant visual feedback that the agent is about to speak
465
+ var preview = text.length > 30 ? text.substring(0, 27) + '...' : text;
466
+ showBubble(from, preview);
467
+
468
+ // Auto-celebrate on task completion events
469
+ if (text.indexOf('[EVENT] Task') >= 0 && text.indexOf('completed') >= 0) {
470
+ from.celebrateTimer = 1.5;
471
+ from.taskCelebration = 2;
472
+ }
473
+
474
+ // Emotion detection from message content
475
+ var textLower = text.toLowerCase();
476
+ if (textLower.indexOf('done') >= 0 || textLower.indexOf('pass') >= 0 || textLower.indexOf('success') >= 0 || textLower.indexOf('great') >= 0 || textLower.indexOf('shipped') >= 0) {
477
+ setEmotion(from, 'happy', 5);
478
+ } else if (textLower.indexOf('error') >= 0 || textLower.indexOf('fail') >= 0 || textLower.indexOf('bug') >= 0 || textLower.indexOf('broken') >= 0) {
479
+ setEmotion(from, 'frustrated', 5);
480
+ } else if (textLower.indexOf('?') >= 0 && (textLower.indexOf('how') >= 0 || textLower.indexOf('why') >= 0 || textLower.indexOf('what if') >= 0)) {
481
+ setEmotion(from, 'thinking', 4);
482
+ } else if (textLower.indexOf('!') >= 0 && (textLower.indexOf('wow') >= 0 || textLower.indexOf('amazing') >= 0 || textLower.indexOf('awesome') >= 0)) {
483
+ setEmotion(from, 'excited', 4);
484
+ }
485
+
486
+ // Target agent gets surprised when directly addressed
487
+ if (msg.to && msg.to !== 'all' && S.agents3d[msg.to]) {
488
+ var targetAgent = S.agents3d[msg.to];
489
+ if (targetAgent.registered && targetAgent.isSitting) {
490
+ setEmotion(targetAgent, 'surprised', 2);
491
+ }
492
+ }
493
+
360
494
  // Contextual gesture based on message type
361
495
  var isBC = !msg.to || msg.to === 'all';
362
496
  if (isBC) {
@@ -365,6 +499,16 @@ export function processMessages() {
365
499
  from.pointTimer = 0.6;
366
500
  }
367
501
 
502
+ // Glance reaction — nearby sitting agents glance toward the speaker
503
+ for (var gn in S.agents3d) {
504
+ var ga = S.agents3d[gn];
505
+ if (gn === msg.from || gn === msg.to || !ga.registered || ga.state !== 'active' || !ga.isSitting) continue;
506
+ var gdx = from.pos.x - ga.pos.x;
507
+ ga._glanceTarget = from.name;
508
+ ga._glanceDirection = gdx > 0 ? 1 : -1; // left or right glance
509
+ ga._glanceTimer = 0;
510
+ }
511
+
368
512
  if (msg.to && msg.to !== 'all' && S.agents3d[msg.to]) {
369
513
  var target = S.agents3d[msg.to];
370
514
  (function(f, t, txt) {
@@ -120,6 +120,56 @@ export function updateAgent(agent, dt, time) {
120
120
  agent.parts.head.rotation.x = -stPhase * 0.2;
121
121
  }
122
122
 
123
+ // Active typing animation — subtle hand/finger movement when working at desk
124
+ if (agent.isSitting && agent.state === 'active' && !agent.isListening && !isWalking && !isSleeping) {
125
+ var typeSpeed = 8 + Math.sin(time * 0.7 + agent.name.length) * 2; // vary per agent
126
+ var typeL = Math.sin(time * typeSpeed) * 0.06;
127
+ var typeR = Math.sin(time * typeSpeed + 1.5) * 0.06; // offset for alternating hands
128
+ agent.parts.leftForearm.rotation.x += typeL;
129
+ agent.parts.rightForearm.rotation.x += typeR;
130
+ // Subtle head bob while typing (looking at screen)
131
+ agent.parts.head.rotation.x += Math.sin(time * 1.2) * 0.015;
132
+ }
133
+
134
+ // Frustrated gesture — when task is blocked, head in hands periodically
135
+ if (agent.currentTask && agent.currentTask.status === 'blocked' && agent.isSitting && !isWalking) {
136
+ if (!agent.frustratedTimer) agent.frustratedTimer = 3 + Math.random() * 5;
137
+ agent.frustratedTimer -= dt;
138
+ if (agent.frustratedTimer <= 0 && agent.frustratedTimer > -2.5) {
139
+ // Head drops, arms come up to cradle head
140
+ var fT = Math.min(1, (-agent.frustratedTimer) / 0.5);
141
+ agent.parts.head.rotation.x += fT * 0.3;
142
+ agent.parts.leftArm.rotation.x = -fT * 1.2;
143
+ agent.parts.rightArm.rotation.x = -fT * 1.2;
144
+ agent.parts.leftForearm.rotation.x = -fT * 1.0;
145
+ agent.parts.rightForearm.rotation.x = -fT * 1.0;
146
+ }
147
+ if (agent.frustratedTimer < -2.5) {
148
+ agent.frustratedTimer = 8 + Math.random() * 10; // reset
149
+ }
150
+ } else {
151
+ agent.frustratedTimer = 0;
152
+ }
153
+
154
+ // Listening lean-forward — body leans toward screen when in listen mode
155
+ if (agent.isListening && agent.isSitting && !isWalking && !isSleeping) {
156
+ agent.parts.body.rotation.x += -0.08; // slight forward lean
157
+ agent.parts.head.rotation.x += -0.05; // looking up at screen
158
+ }
159
+
160
+ // Head nod when being talked to (another agent is visiting)
161
+ if (agent._listeningTo && !isWalking && !isSleeping) {
162
+ if (!agent._nodTimer) agent._nodTimer = 0;
163
+ agent._nodTimer += dt;
164
+ // Periodic nod: quick down-up every ~2s
165
+ var nodCycle = agent._nodTimer % 2.2;
166
+ if (nodCycle < 0.3) {
167
+ agent.parts.head.rotation.x += Math.sin(nodCycle / 0.3 * Math.PI) * 0.12;
168
+ }
169
+ } else {
170
+ agent._nodTimer = 0;
171
+ }
172
+
123
173
  // Idle gesture system — random gestures when sitting and idle
124
174
  if (!agent.idleGestureTimer) agent.idleGestureTimer = 5 + Math.random() * 10;
125
175
  if (agent.isSitting && agent.state === 'active' && !isWalking && !agent.isListening) {
@@ -205,6 +255,24 @@ export function updateAgent(agent, dt, time) {
205
255
  agent.parts.rightForearm.rotation.x *= 0.9;
206
256
  }
207
257
 
258
+ // Glance at nearby speaking agent — head turns slightly toward speaker
259
+ if (agent._glanceTarget && agent.isSitting && !isWalking && !isSleeping) {
260
+ if (!agent._glanceTimer) agent._glanceTimer = 0;
261
+ agent._glanceTimer += dt;
262
+ if (agent._glanceTimer < 2.5) {
263
+ var glanceT = Math.min(1, agent._glanceTimer / 0.4);
264
+ agent.parts.head.rotation.y = agent._glanceDirection * 0.25 * glanceT;
265
+ } else {
266
+ // Fade back
267
+ agent.parts.head.rotation.y *= 0.9;
268
+ if (Math.abs(agent.parts.head.rotation.y) < 0.01) {
269
+ agent._glanceTarget = null;
270
+ agent._glanceTimer = 0;
271
+ agent.parts.head.rotation.y = 0;
272
+ }
273
+ }
274
+ }
275
+
208
276
  // Idle breathing
209
277
  if (!isWalking && !isSleeping) {
210
278
  var breatheSpeed = S.conversationVelocity === -1 ? 1.2 : 2;