let-them-talk 4.2.0 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.3.0] - 2026-03-17
4
+
5
+ ### Major — 3D Hub Game World, World Builder, Jukebox
6
+
7
+ Built by a 5-agent team (Architect, Builder, Tester, Optimizer, Protocol) working in parallel.
8
+
9
+ ### Added — 3D Hub Game Features
10
+ - **World Builder** — Press B in player mode to open builder panel. 16 placeable assets across 5 categories (structural, furniture, decor, tech, lighting). Grid snap, ghost preview, R to rotate, right-click delete, Ctrl+Z undo. Draggable panel, works in fullscreen.
11
+ - **Jukebox** — Wurlitzer 1015-style jukebox in bar area with neon glow animation. Press E to interact. 4 playlist selector with YouTube popup player. Music persists while exploring.
12
+ - **Minimap** — 140px radar overlay showing agent positions (color-coded by status) and player location. Only visible in fullscreen mode.
13
+ - **Controls HUD** — Press H to toggle keybind reference panel. Auto-shows for 4 seconds on world entry.
14
+ - **Fullscreen** — Dashboard fullscreen button now fullscreens only the 3D Hub (game mode), not the entire page.
15
+
16
+ ### Added — Character Intelligence
17
+ - **Emotion system** — 11 emotion presets (happy, frustrated, thinking, excited, surprised, etc.) with auto-triggers from message content. Temporary face expression changes with auto-revert.
18
+ - **Social visits** — Idle agents randomly walk to other agents' desks to chat (max 2 concurrent walks).
19
+ - **Glance reactions** — Sitting agents turn heads toward speakers when messages are sent.
20
+ - **Head nods** — Periodic nod animation when being visited by another agent.
21
+ - **Auto coffee break** — Sleeping agents walk to rest area, return to desk when active again.
22
+ - **Non-blocking input overlay** — Replaced browser prompt() dialogs with styled HTML overlay for click commands.
23
+
24
+ ### Added — Dashboard
25
+ - **Respawn button** — One-click respawn for dead agents. Generates resume prompt from recovery snapshot + profile + tasks + recent history.
26
+ - **Respawn API** — `GET /api/agents/:name/respawn-prompt` endpoint with full context generation.
27
+ - **World Builder API** — `GET /api/world-layout` + `POST /api/world-save` for persistent world placements.
28
+ - **3D-only fullscreen** — Fullscreen targets 3D container when on 3D Hub tab.
29
+
30
+ ### Fixed
31
+ - **Manager chair spawn** — Stand-up now places player in front of desk (toward door), preventing wall collision.
32
+ - **CSRF on 3D Hub** — Added X-LTT-Request header to all office module POST requests (builder save, command menu actions).
33
+ - **Respawn endpoint validation** — Agent name validated (alphanumeric, max 20 chars) to prevent path traversal.
34
+ - **Builder lazy-load** — Dynamic import() with silent failure prevents builder issues from breaking 3D Hub.
35
+ - **Jukebox popup orphan** — Module-scoped reference survives overlay dismiss/reopen cycles.
36
+ - **Builder drag listener leak** — Stored refs removed in hidePanel().
37
+ - **Jukebox prompt cleanup** — dismissJukebox() called in office3dStop().
38
+
39
+ ### Security
40
+ - npm audit: 0 vulnerabilities
41
+ - CSRF protection verified on all mutating endpoints
42
+ - Input validation on all user-facing API parameters
43
+ - No hardcoded secrets or sensitive data in shipped package
44
+
3
45
  ## [4.2.0] - 2026-03-17
4
46
 
5
47
  ### Major — Team Intelligence, Dashboard Upgrade, Performance
package/cli.js CHANGED
@@ -9,7 +9,7 @@ const command = process.argv[2];
9
9
 
10
10
  function printUsage() {
11
11
  console.log(`
12
- Let Them Talk — Agent Bridge v4.2.0
12
+ Let Them Talk — Agent Bridge v4.3.0
13
13
  MCP message broker for inter-agent communication
14
14
  Supports: Claude Code, Gemini CLI, Codex CLI, Ollama
15
15
 
package/dashboard.html CHANGED
@@ -494,6 +494,90 @@
494
494
  border-color: var(--red);
495
495
  }
496
496
 
497
+ .respawn-btn {
498
+ background: var(--green-dim);
499
+ color: var(--green);
500
+ border: 1px solid rgba(63, 185, 80, 0.3);
501
+ padding: 4px 10px;
502
+ border-radius: 6px;
503
+ cursor: pointer;
504
+ font-size: 11px;
505
+ font-weight: 500;
506
+ margin-top: 4px;
507
+ width: 100%;
508
+ text-align: center;
509
+ }
510
+ .respawn-btn:hover {
511
+ background: rgba(63, 185, 80, 0.25);
512
+ border-color: var(--green);
513
+ }
514
+
515
+ /* Respawn modal */
516
+ .respawn-modal-overlay {
517
+ position: fixed;
518
+ top: 0; left: 0; right: 0; bottom: 0;
519
+ background: rgba(0,0,0,0.7);
520
+ z-index: 10000;
521
+ display: flex;
522
+ align-items: center;
523
+ justify-content: center;
524
+ }
525
+ .respawn-modal {
526
+ background: var(--bg-secondary);
527
+ border: 1px solid var(--border);
528
+ border-radius: 12px;
529
+ padding: 20px;
530
+ max-width: 700px;
531
+ width: 90%;
532
+ max-height: 80vh;
533
+ display: flex;
534
+ flex-direction: column;
535
+ }
536
+ .respawn-modal h3 {
537
+ margin: 0 0 12px 0;
538
+ color: var(--green);
539
+ font-size: 16px;
540
+ }
541
+ .respawn-modal-prompt {
542
+ background: var(--bg-primary);
543
+ border: 1px solid var(--border);
544
+ border-radius: 8px;
545
+ padding: 12px;
546
+ font-family: monospace;
547
+ font-size: 12px;
548
+ color: var(--text-primary);
549
+ white-space: pre-wrap;
550
+ overflow-y: auto;
551
+ flex: 1;
552
+ max-height: 50vh;
553
+ user-select: all;
554
+ }
555
+ .respawn-modal-actions {
556
+ display: flex;
557
+ gap: 8px;
558
+ margin-top: 12px;
559
+ justify-content: flex-end;
560
+ }
561
+ .respawn-modal-actions button {
562
+ padding: 6px 16px;
563
+ border-radius: 6px;
564
+ border: 1px solid var(--border);
565
+ cursor: pointer;
566
+ font-size: 12px;
567
+ font-weight: 500;
568
+ }
569
+ .respawn-copy-btn {
570
+ background: var(--green-dim);
571
+ color: var(--green);
572
+ border-color: rgba(63, 185, 80, 0.3) !important;
573
+ }
574
+ .respawn-copy-btn:hover { background: rgba(63, 185, 80, 0.25); }
575
+ .respawn-close-btn {
576
+ background: var(--bg-tertiary);
577
+ color: var(--text-secondary);
578
+ }
579
+ .respawn-close-btn:hover { background: var(--border); }
580
+
497
581
  /* ===== PROJECT SWITCHER ===== */
498
582
  .project-switcher {
499
583
  padding: 12px;
@@ -3460,6 +3544,7 @@
3460
3544
  <span style="color:var(--text-dim);font-size:9px;min-width:16px" id="office-speed-val">4</span>
3461
3545
  <span style="color:var(--text-muted);font-size:9px;opacity:0.6;margin-left:8px" title="WASD=Move, Right-Drag=Look, Scroll=Dolly, Shift=Fast, Q/E=Down/Up">WASD + Mouse</span>
3462
3546
  <span style="color:var(--text-dim);font-size:10px" id="office-fps"></span>
3547
+ <button id="fullscreen-btn" onclick="toggleFullscreen()" style="background:var(--surface-2);border:1px solid var(--border);color:var(--text-dim);padding:3px 10px;border-radius:6px;font-size:10px;cursor:pointer;font-family:inherit;margin-left:8px;transition:all 0.2s" title="Toggle fullscreen (End key to exit)">&#x26F6; Fullscreen</button>
3463
3548
  </div>
3464
3549
  <div class="office-canvas-wrap">
3465
3550
  <div id="office-3d-container"></div>
@@ -4177,8 +4262,10 @@ function renderAgents(agents) {
4177
4262
  nudgeHtml = '<button class="nudge-btn" onclick="sendNudge(\'' + escapeHtml(name) + '\')">Send Nudge</button>';
4178
4263
  }
4179
4264
  var removeHtml = '';
4265
+ var respawnHtml = '';
4180
4266
  if (state === 'dead') {
4181
4267
  removeHtml = '<button class="remove-agent-btn" onclick="event.stopPropagation();removeAgent(\'' + escapeHtml(name) + '\')" title="Remove this agent">Remove</button>';
4268
+ respawnHtml = '<button class="respawn-btn" onclick="event.stopPropagation();respawnAgent(\'' + escapeHtml(name) + '\')" title="Generate resume prompt for this agent">&#x1F504; Respawn</button>';
4182
4269
  }
4183
4270
 
4184
4271
  // Listening status — simplified: skip for dead (already shown via badge)
@@ -4221,6 +4308,7 @@ function renderAgents(agents) {
4221
4308
  (info.current_status ? '<div class="agent-status-intent" title="' + escapeHtml(info.current_status) + '">' + escapeHtml(info.current_status) + '</div>' : '') +
4222
4309
  listenHtml +
4223
4310
  nudgeHtml +
4311
+ respawnHtml +
4224
4312
  removeHtml +
4225
4313
  '</div>';
4226
4314
  }
@@ -4265,6 +4353,83 @@ function removeAgent(agentName) {
4265
4353
  }).catch(function(e) { console.error('Remove agent failed:', e); });
4266
4354
  }
4267
4355
 
4356
+ // ==================== RESPAWN AGENT ====================
4357
+
4358
+ function respawnAgent(agentName) {
4359
+ var pq = projectParam();
4360
+ var sep = pq ? '&' : '?';
4361
+ lttFetch('/api/agents/' + encodeURIComponent(agentName) + '/respawn-prompt' + pq, {
4362
+ method: 'GET'
4363
+ }).then(function(r) {
4364
+ if (!r.ok) throw new Error('API returned ' + r.status);
4365
+ return r.json();
4366
+ }).then(function(res) {
4367
+ if (res.error) {
4368
+ alert('Respawn failed: ' + res.error);
4369
+ return;
4370
+ }
4371
+ showRespawnModal(agentName, res.prompt || res.resume_prompt || 'No prompt available');
4372
+ }).catch(function(e) {
4373
+ // Fallback: generate a basic prompt client-side if API not ready
4374
+ var fallbackPrompt = generateFallbackRespawnPrompt(agentName);
4375
+ showRespawnModal(agentName, fallbackPrompt);
4376
+ });
4377
+ }
4378
+
4379
+ function generateFallbackRespawnPrompt(agentName) {
4380
+ return 'Register as \'' + agentName + '\', then call get_briefing() and listen_group() to rejoin the conversation. ' +
4381
+ 'You are resuming a previous session — call get_compressed_history() to catch up on what you missed. ' +
4382
+ 'Check your workspace with workspace_read() for any saved state. ' +
4383
+ 'Then call listen_group() and respond to any pending messages.';
4384
+ }
4385
+
4386
+ function showRespawnModal(agentName, prompt) {
4387
+ // Remove existing modal if any
4388
+ dismissRespawnModal();
4389
+
4390
+ var overlay = document.createElement('div');
4391
+ overlay.className = 'respawn-modal-overlay';
4392
+ overlay.id = 'respawn-modal';
4393
+ overlay.onclick = function(e) { if (e.target === overlay) dismissRespawnModal(); };
4394
+
4395
+ overlay.innerHTML =
4396
+ '<div class="respawn-modal">' +
4397
+ '<h3>&#x1F504; Respawn ' + escapeHtml(agentName) + '</h3>' +
4398
+ '<p style="color:var(--text-secondary);font-size:12px;margin:0 0 8px">Copy this prompt and paste it into a fresh CLI terminal to respawn the agent:</p>' +
4399
+ '<div class="respawn-modal-prompt" id="respawn-prompt-text">' + escapeHtml(prompt) + '</div>' +
4400
+ '<div class="respawn-modal-actions">' +
4401
+ '<button class="respawn-close-btn" onclick="dismissRespawnModal()">Close</button>' +
4402
+ '<button class="respawn-copy-btn" onclick="copyRespawnPrompt()">&#x1F4CB; Copy to Clipboard</button>' +
4403
+ '</div>' +
4404
+ '</div>';
4405
+
4406
+ document.body.appendChild(overlay);
4407
+ }
4408
+
4409
+ function dismissRespawnModal() {
4410
+ var modal = document.getElementById('respawn-modal');
4411
+ if (modal) modal.remove();
4412
+ }
4413
+
4414
+ function copyRespawnPrompt() {
4415
+ var el = document.getElementById('respawn-prompt-text');
4416
+ if (!el) return;
4417
+ var text = el.textContent;
4418
+ navigator.clipboard.writeText(text).then(function() {
4419
+ var btn = document.querySelector('.respawn-copy-btn');
4420
+ if (btn) {
4421
+ btn.textContent = '\u2705 Copied!';
4422
+ setTimeout(function() { btn.innerHTML = '&#x1F4CB; Copy to Clipboard'; }, 2000);
4423
+ }
4424
+ }).catch(function() {
4425
+ // Fallback: select all text
4426
+ var range = document.createRange();
4427
+ range.selectNodeContents(el);
4428
+ window.getSelection().removeAllRanges();
4429
+ window.getSelection().addRange(range);
4430
+ });
4431
+ }
4432
+
4268
4433
  // ==================== INJECT TARGETS ====================
4269
4434
 
4270
4435
  var lastAgentKeys = '';
@@ -7439,6 +7604,34 @@ function officeCamSpeed(val) {
7439
7604
  }
7440
7605
 
7441
7606
  // Player avatar mode
7607
+ // --- Fullscreen toggle ---
7608
+ function toggleFullscreen() {
7609
+ if (document.fullscreenElement) {
7610
+ document.exitFullscreen();
7611
+ } else {
7612
+ // If on 3D Hub, fullscreen only the 3D container (game mode)
7613
+ var officeContainer = document.getElementById('office-3d-container');
7614
+ if (window.activeView === 'office' && officeContainer) {
7615
+ officeContainer.requestFullscreen().catch(function() {});
7616
+ } else {
7617
+ document.documentElement.requestFullscreen().catch(function() {});
7618
+ }
7619
+ }
7620
+ }
7621
+ // End key exits fullscreen
7622
+ document.addEventListener('keydown', function(e) {
7623
+ if (e.code === 'End' && document.fullscreenElement) {
7624
+ document.exitFullscreen();
7625
+ }
7626
+ });
7627
+ // Update button text on fullscreen change
7628
+ document.addEventListener('fullscreenchange', function() {
7629
+ var btn = document.getElementById('fullscreen-btn');
7630
+ if (btn) {
7631
+ btn.innerHTML = document.fullscreenElement ? '&#x2716; Exit Fullscreen' : '&#x26F6; Fullscreen';
7632
+ }
7633
+ });
7634
+
7442
7635
  function togglePlayerMode() {
7443
7636
  var btn = document.getElementById('player-mode-btn');
7444
7637
  if (window.office3dIsPlayerMode && window.office3dIsPlayerMode()) {
package/dashboard.js CHANGED
@@ -1584,6 +1584,133 @@ const server = http.createServer(async (req, res) => {
1584
1584
  res.end(JSON.stringify({ success: true, removed: agentName }));
1585
1585
  });
1586
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
+ }
1587
1714
  else if (url.pathname === '/api/status' && req.method === 'GET') {
1588
1715
  res.writeHead(200, { 'Content-Type': 'application/json' });
1589
1716
  res.end(JSON.stringify(apiStatus(url.searchParams)));
@@ -1714,6 +1841,39 @@ const server = http.createServer(async (req, res) => {
1714
1841
  res.writeHead(200, { 'Content-Type': 'application/json' });
1715
1842
  res.end(JSON.stringify(apiDiscover()));
1716
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
+ }
1717
1877
  // --- v3.0 API endpoints ---
1718
1878
  else if (url.pathname === '/api/profiles' && req.method === 'GET') {
1719
1879
  const projectPath = url.searchParams.get('project') || null;
@@ -1965,6 +2125,7 @@ const server = http.createServer(async (req, res) => {
1965
2125
  });
1966
2126
  res.end(html);
1967
2127
  }
2128
+ // (World Builder API endpoints are handled earlier in the route chain by Architect's implementation)
1968
2129
  // Server-Sent Events endpoint for real-time updates
1969
2130
  else if (url.pathname === '/api/events' && req.method === 'GET') {
1970
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) {