Compare commits

..

13 Commits

Author SHA1 Message Date
Alexander Whitestone
5ff0257c0f feat: 3D audio spatial chat — volume based on distance (fix #1544)
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 11s
CI / test (pull_request) Failing after 1m14s
CI / validate (pull_request) Failing after 1m17s
- Add SpatialChatAudio component with distance-based volume rolloff
- Each agent gets distinct tone (freq + waveform)
- Stereo panning based on relative position to camera
- HRTF PannerNode support for voice chat streams
- Integrate with addChatMessage for spatial notifications
- Tests + docs included
2026-04-22 02:55:20 -04:00
c97364ac13 [claude] ATLAS Cockpit: operator inspector rail and session shell (#1695) (#1696)
Some checks failed
Deploy Nexus / deploy (push) Failing after 5s
Staging Verification Gate / verify-staging (push) Failing after 5s
2026-04-22 05:19:13 +00:00
324cdb0d26 Merge PR #1684
Some checks failed
Deploy Nexus / deploy (push) Failing after 7s
Staging Verification Gate / verify-staging (push) Failing after 13s
Merge PR #1684: portal hot-reload
2026-04-22 03:15:13 +00:00
b4473267e0 Merge PR #1685
Some checks failed
Deploy Nexus / deploy (push) Failing after 5s
Staging Verification Gate / verify-staging (push) Failing after 6s
Merge PR #1685: test collection errors
2026-04-22 03:15:07 +00:00
ed733d4eea Merge PR #1686
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Has been cancelled
Merge PR #1686: A11Y text contrast
2026-04-22 03:15:03 +00:00
7c9f4310d0 Merge branch 'main' into fix/1536-hot-reload
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 8s
CI / test (pull_request) Failing after 1m8s
CI / validate (pull_request) Failing after 1m7s
2026-04-22 01:12:04 +00:00
2016a7e076 Merge branch 'main' into fix/1509-tests
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 10s
CI / test (pull_request) Failing after 1m9s
CI / validate (pull_request) Failing after 1m14s
2026-04-22 01:11:58 +00:00
b6ee9ba01b Merge branch 'main' into mimo/code/issue-702
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 10s
CI / test (pull_request) Failing after 1m10s
CI / validate (pull_request) Failing after 1m13s
2026-04-22 01:11:53 +00:00
15b9a4398c Merge branch 'main' into fix/1536-hot-reload
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 8s
CI / test (pull_request) Failing after 1m7s
CI / validate (pull_request) Failing after 1m11s
2026-04-22 01:05:01 +00:00
3f7277d920 Merge branch 'main' into fix/1509-tests
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 12s
CI / test (pull_request) Failing after 1m10s
CI / validate (pull_request) Failing after 1m12s
2026-04-22 01:04:55 +00:00
cb944be172 Merge branch 'main' into mimo/code/issue-702
Some checks failed
CI / test (pull_request) Failing after 1m10s
Review Approval Gate / verify-review (pull_request) Failing after 12s
CI / validate (pull_request) Failing after 1m8s
2026-04-22 01:04:50 +00:00
Alexander Whitestone
ec2ed3c62f fix: test collection errors in bannerlord and evennia tests (closes #1509)
Some checks failed
CI / test (pull_request) Failing after 1m22s
CI / validate (pull_request) Failing after 1m3s
Review Approval Gate / verify-review (pull_request) Failing after 4s
- nexus/bannerlord_harness.py: fixed bare import to absolute
- nexus/evennia_ws_bridge.py: added clean_lines, normalize_event,
  parse_room_output functions that tests expected

Test results:
- test_bannerlord_harness.py: 39 tests collected
- test_evennia_ws_bridge.py: 5 tests collected
2026-04-21 08:08:49 -04:00
Alexander Whitestone
11175e72c0 feat: portal hot-reload from portals.json without server restart (closes #1536)
Some checks failed
CI / test (pull_request) Failing after 1m20s
CI / validate (pull_request) Failing after 1m24s
Review Approval Gate / verify-review (pull_request) Failing after 9s
2026-04-21 08:01:56 -04:00
19 changed files with 2243 additions and 1273 deletions

View File

@@ -1,112 +0,0 @@
---
name: Mission Proposal
about: Propose a new mission for the Hermes fleet
title: "[MISSION] "
labels: mission, proposal
assignees: ''
---
## Mission Proposal
### Mission Name
<!-- Provide a clear, descriptive name for the mission -->
### Objective
<!-- What is the primary goal of this mission? -->
### Scope
<!-- What is included and excluded from this mission? -->
**Included:**
-
-
-
**Excluded:**
-
-
-
### Agents Required
<!-- List the agents needed for this mission -->
| Agent ID | Role | Capabilities | Gateway |
|----------|------|--------------|---------|
| agent_001 | lead | planning, coordination | gateway_1 |
| agent_002 | write | coding, testing | gateway_2 |
### Tasks
<!-- List the tasks for this mission -->
| Task ID | Title | Assigned To | Priority | Dependencies |
|---------|-------|-------------|----------|--------------|
| task_001 | Code Review | agent_002 | high | none |
| task_002 | Testing | agent_002 | medium | task_001 |
### Isolation Requirements
<!-- What isolation level is needed? -->
- [ ] None (development/testing)
- [ ] Level 1 (process isolation)
- [ ] Level 2 (mount namespace)
- [ ] Level 3 (rootless Podman)
### Resource Requirements
<!-- What resources are needed? -->
| Resource | Amount | Notes |
|----------|--------|-------|
| CPU | 2 cores | |
| Memory | 4GB | |
| Disk | 10GB | |
| Network | Standard | |
### Timeline
<!-- What is the expected timeline? -->
| Phase | Duration | Description |
|-------|----------|-------------|
| Setup | 1 hour | Configure mission cell |
| Execution | 4 hours | Run mission tasks |
| Cleanup | 30 minutes | Collect artifacts |
### Success Criteria
<!-- How will we know the mission succeeded? -->
- [ ] All tasks completed
- [ ] All artifacts collected
- [ ] All agents healthy
- [ ] No critical errors
### Risks and Mitigations
<!-- What could go wrong and how will we handle it? -->
| Risk | Impact | Mitigation |
|------|--------|------------|
| Agent failure | High | Auto-revive with resurrection pool |
| Task timeout | Medium | Increase timeout or reassign |
| Resource exhaustion | High | Monitor and scale as needed |
### Dependencies
<!-- What does this mission depend on? -->
- [ ] Gateways configured
- [ ] Agents registered
- [ ] Resources allocated
- [ ] Permissions granted
### Approval
<!-- Who needs to approve this mission? -->
- [ ] Technical review
- [ ] Resource approval
- [ ] Security review
- [ ] Final approval
### Notes
<!-- Any additional notes or context -->
---
**Proposer:** [Your name/agent]
**Date:** [Date]
**Parent Epic:** #878

View File

@@ -1,309 +0,0 @@
"""
Agent Health Heartbeat Endpoint
Issue #879: [M6-P0] Foundation — cell spec, daemon skeleton, health heartbeat
Health heartbeat endpoint for agent monitoring.
"""
import asyncio
import json
import logging
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
logger = logging.getLogger("agent.health")
class AgentHealthStatus:
"""Health status of an agent."""
def __init__(self, agent_id: str):
self.agent_id = agent_id
self.status = "healthy"
self.last_heartbeat = time.time()
self.last_response = time.time()
self.consecutive_failures = 0
self.response_time = 0.0
self.error_message: Optional[str] = None
self.metadata: Dict[str, Any] = {}
def update_heartbeat(self, response_time: float = 0.0, metadata: Optional[Dict] = None):
"""Update heartbeat status."""
self.last_heartbeat = time.time()
self.last_response = time.time()
self.response_time = response_time
self.consecutive_failures = 0
self.error_message = None
if metadata:
self.metadata.update(metadata)
def mark_failure(self, error_message: str):
"""Mark a failure."""
self.consecutive_failures += 1
self.last_response = time.time()
self.error_message = error_message
if self.consecutive_failures >= 3:
self.status = "unhealthy"
else:
self.status = "degraded"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"agent_id": self.agent_id,
"status": self.status,
"last_heartbeat": self.last_heartbeat,
"last_response": self.last_response,
"consecutive_failures": self.consecutive_failures,
"response_time": self.response_time,
"error_message": self.error_message,
"metadata": self.metadata
}
class HealthHeartbeatEndpoint:
"""Health heartbeat endpoint for agent monitoring."""
def __init__(self):
self.agents: Dict[str, AgentHealthStatus] = {}
self.heartbeat_interval = 30 # seconds
self.timeout = 300 # seconds
def register_agent(self, agent_id: str, metadata: Optional[Dict] = None):
"""Register an agent for health monitoring."""
if agent_id not in self.agents:
self.agents[agent_id] = AgentHealthStatus(agent_id)
logger.info(f"Registered agent for health monitoring: {agent_id}")
if metadata:
self.agents[agent_id].metadata.update(metadata)
def unregister_agent(self, agent_id: str):
"""Unregister an agent from health monitoring."""
if agent_id in self.agents:
del self.agents[agent_id]
logger.info(f"Unregistered agent from health monitoring: {agent_id}")
async def handle_heartbeat(self, agent_id: str, data: Optional[Dict] = None) -> Dict[str, Any]:
"""Handle heartbeat from an agent."""
if agent_id not in self.agents:
self.register_agent(agent_id)
agent = self.agents[agent_id]
# Calculate response time (simplified)
response_time = 0.1 # Would be calculated from actual request
# Update heartbeat
metadata = data.get("metadata") if data else None
agent.update_heartbeat(response_time, metadata)
logger.debug(f"Heartbeat received from {agent_id}")
return {
"status": "ok",
"agent_id": agent_id,
"timestamp": time.time(),
"next_heartbeat": self.heartbeat_interval
}
async def handle_failure(self, agent_id: str, error_message: str):
"""Handle failure from an agent."""
if agent_id not in self.agents:
self.register_agent(agent_id)
agent = self.agents[agent_id]
agent.mark_failure(error_message)
logger.warning(f"Agent {agent_id} failure: {error_message}")
def get_agent_health(self, agent_id: str) -> Optional[Dict[str, Any]]:
"""Get health status for a specific agent."""
agent = self.agents.get(agent_id)
return agent.to_dict() if agent else None
def get_all_health(self) -> Dict[str, Any]:
"""Get health status for all agents."""
healthy = sum(1 for a in self.agents.values() if a.status == "healthy")
degraded = sum(1 for a in self.agents.values() if a.status == "degraded")
unhealthy = sum(1 for a in self.agents.values() if a.status == "unhealthy")
return {
"timestamp": time.time(),
"total_agents": len(self.agents),
"healthy": healthy,
"degraded": degraded,
"unhealthy": unhealthy,
"agents": {agent_id: agent.to_dict() for agent_id, agent in self.agents.items()}
}
def check_timeouts(self):
"""Check for agents that have timed out."""
current_time = time.time()
timed_out = []
for agent_id, agent in self.agents.items():
time_since_response = current_time - agent.last_response
if time_since_response > self.timeout:
agent.mark_failure(f"No response for {time_since_response:.0f} seconds")
timed_out.append(agent_id)
logger.warning(f"Agent {agent_id} timed out")
return timed_out
class HealthHeartbeatServer:
"""HTTP server for health heartbeat endpoint."""
def __init__(self, host: str = "0.0.0.0", port: int = 8080):
self.host = host
self.port = port
self.endpoint = HealthHeartbeatEndpoint()
self.running = False
async def handle_request(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
"""Handle incoming HTTP request."""
try:
# Read request
data = await reader.read(1024)
request = data.decode()
# Parse request (simplified)
lines = request.split('\n')
if not lines:
return
# Parse method and path
first_line = lines[0]
parts = first_line.split()
if len(parts) < 2:
return
method = parts[0]
path = parts[1]
# Route request
if method == "GET" and path == "/health":
response = await self._handle_health_check()
elif method == "POST" and path.startswith("/heartbeat/"):
agent_id = path.split("/")[-1]
response = await self._handle_heartbeat(agent_id, data)
elif method == "GET" and path.startswith("/agent/"):
agent_id = path.split("/")[-1]
response = await self._handle_agent_health(agent_id)
else:
response = self._create_response(404, "Not Found")
# Send response
writer.write(response.encode())
await writer.drain()
except Exception as e:
logger.error(f"Request handling error: {e}")
error_response = self._create_response(500, "Internal Server Error")
writer.write(error_response.encode())
await writer.drain()
finally:
writer.close()
async def _handle_health_check(self) -> str:
"""Handle health check request."""
health = self.endpoint.get_all_health()
return self._create_json_response(200, health)
async def _handle_heartbeat(self, agent_id: str, data: bytes) -> str:
"""Handle heartbeat from agent."""
try:
# Parse request body
body = data.decode().split('\r\n\r\n', 1)[-1]
request_data = json.loads(body) if body else {}
# Handle heartbeat
result = await self.endpoint.handle_heartbeat(agent_id, request_data)
return self._create_json_response(200, result)
except Exception as e:
logger.error(f"Heartbeat handling error: {e}")
return self._create_response(400, "Bad Request")
async def _handle_agent_health(self, agent_id: str) -> str:
"""Handle agent health request."""
health = self.endpoint.get_agent_health(agent_id)
if health:
return self._create_json_response(200, health)
else:
return self._create_response(404, "Agent not found")
def _create_response(self, status_code: int, message: str) -> str:
"""Create HTTP response."""
return f"HTTP/1.1 {status_code} {message}\r\nContent-Type: text/plain\r\nContent-Length: {len(message)}\r\n\r\n{message}"
def _create_json_response(self, status_code: int, data: Dict) -> str:
"""Create JSON HTTP response."""
body = json.dumps(data)
return f"HTTP/1.1 {status_code} OK\r\nContent-Type: application/json\r\nContent-Length: {len(body)}\r\n\r\n{body}"
async def start(self):
"""Start the server."""
self.running = True
logger.info(f"Starting health heartbeat server on {self.host}:{self.port}")
server = await asyncio.start_server(
self.handle_request,
self.host,
self.port
)
async with server:
await server.serve_forever()
async def stop(self):
"""Stop the server."""
self.running = False
logger.info("Health heartbeat server stopped")
# Example usage
def create_example_heartbeat_server() -> HealthHeartbeatServer:
"""Create example heartbeat server."""
server = HealthHeartbeatServer(host="127.0.0.1", port=8080)
# Register some agents
server.endpoint.register_agent("agent_001", {"role": "lead", "gateway": "gateway_1"})
server.endpoint.register_agent("agent_002", {"role": "write", "gateway": "gateway_2"})
return server
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Agent Health Heartbeat Endpoint")
parser.add_argument("--host", default="127.0.0.1", help="Host to bind to")
parser.add_argument("--port", type=int, default=8080, help="Port to bind to")
parser.add_argument("--example", action="store_true", help="Run example server")
args = parser.parse_args()
if args.example:
# Run example server
server = create_example_heartbeat_server()
print(f"Starting example health heartbeat server on {args.host}:{args.port}")
print("Endpoints:")
print(" GET /health - Get all agent health")
print(" POST /heartbeat/<agent_id> - Send heartbeat")
print(" GET /agent/<agent_id> - Get agent health")
print("\nPress Ctrl+C to stop")
try:
asyncio.run(server.start())
except KeyboardInterrupt:
print("\nServer stopped")
else:
parser.print_help()

16
app.js
View File

@@ -5,6 +5,7 @@ import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
import { SpatialMemory } from './nexus/components/spatial-memory.js';
import { SpatialAudio } from './nexus/components/spatial-audio.js';
import { SpatialChatAudio } from './nexus/components/spatial-chat-audio.js';
import { MemoryBirth } from './nexus/components/memory-birth.js';
import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
import { MemoryInspect } from './nexus/components/memory-inspect.js';
@@ -734,6 +735,9 @@ async function init() {
const response = await fetch('./portals.json');
const portalData = await response.json();
createPortals(portalData);
// Start portal hot-reload watcher
if (window.PortalHotReload) PortalHotReload.start(5000);
} catch (e) {
console.error('Failed to load portals.json:', e);
addChatMessage('error', 'Portal registry offline. Check logs.');
@@ -765,6 +769,7 @@ async function init() {
SpatialMemory.setCamera(camera);
SpatialAudio.init(camera, scene);
SpatialAudio.bindSpatialMemory(SpatialMemory);
SpatialChatAudio.init(camera);
MemoryInspect.init({ onNavigate: _navigateToMemory });
MemoryPulse.init(SpatialMemory);
ReasoningTrace.init();
@@ -2963,6 +2968,17 @@ function loadSession() {
function addChatMessage(agent, text, shouldSave = true) {
// Mine chat messages for MemPalace
mineMemPalaceContent(text);
// 3D spatial audio notification (issue #1544)
if (window.SpatialChatAudio && agent !== 'system') {
// Find agent position from tracked agents or fallback to origin
let pos = { x: 0, y: 0, z: 0 };
const agentEntry = Array.isArray(window._trackedAgents) && window._trackedAgents.find(a => a.name === agent);
if (agentEntry && agentEntry.position) {
pos = agentEntry.position;
}
window.SpatialChatAudio.playChatSound(agent, new THREE.Vector3(pos.x, pos.y, pos.z));
}
// Mine chat messages for MemPalace
mineMemPalaceContent(text);
const container = document.getElementById('chat-messages');

View File

@@ -1,358 +0,0 @@
#!/usr/bin/env python3
"""
Lazarus Pit Daemon
Issue #879: [M6-P0] Foundation — cell spec, daemon skeleton, health heartbeat
Daemon that monitors mission cells, detects failures, and triggers revival.
"""
import asyncio
import json
import logging
import os
import signal
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger("lazarus-pit")
class LazarusConfig:
"""Configuration for the Lazarus Pit daemon."""
def __init__(self, config_path: Optional[str] = None):
self.config_path = config_path or os.path.expanduser("~/.lazarus/config.json")
self.config = self._load_config()
def _load_config(self) -> Dict[str, Any]:
"""Load configuration from file."""
if not os.path.exists(self.config_path):
return self._default_config()
try:
with open(self.config_path, "r") as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to load config: {e}")
return self._default_config()
def _default_config(self) -> Dict[str, Any]:
"""Create default configuration."""
return {
"poll_interval": 30, # seconds
"timeout": 300, # seconds
"max_retries": 3,
"auto_revive": True,
"gateways": [],
"missions_path": "/var/missions",
"log_level": "INFO"
}
@property
def poll_interval(self) -> int:
return self.config.get("poll_interval", 30)
@property
def timeout(self) -> int:
return self.config.get("timeout", 300)
@property
def gateways(self) -> List[str]:
return self.config.get("gateways", [])
@property
def missions_path(self) -> str:
return self.config.get("missions_path", "/var/missions")
class MissionCellMonitor:
"""Monitor for mission cells."""
def __init__(self, config: LazarusConfig):
self.config = config
self.cells: Dict[str, Dict[str, Any]] = {}
self.agent_health: Dict[str, Dict[str, Any]] = {}
async def scan_missions(self):
"""Scan for mission cells."""
missions_path = Path(self.config.missions_path)
if not missions_path.exists():
logger.warning(f"Missions path does not exist: {missions_path}")
return
for cell_dir in missions_path.iterdir():
if not cell_dir.is_dir():
continue
cell_json = cell_dir / "cell.json"
if cell_json.exists():
try:
with open(cell_json) as f:
cell_config = json.load(f)
cell_id = cell_config.get("cell_id")
if cell_id:
self.cells[cell_id] = {
"path": str(cell_dir),
"config": cell_config,
"last_scan": time.time()
}
logger.info(f"Found mission cell: {cell_id}")
except Exception as e:
logger.error(f"Failed to load cell config from {cell_dir}: {e}")
async def check_agent_health(self, agent_id: str, gateway: str) -> Dict[str, Any]:
"""Check health of an agent."""
# In production, this would make HTTP request to gateway
# For now, simulate with random success
import random
is_healthy = random.random() > 0.1
return {
"agent_id": agent_id,
"gateway": gateway,
"healthy": is_healthy,
"timestamp": time.time(),
"response_time": random.uniform(0.1, 2.0)
}
async def scan_all_agents(self):
"""Scan health of all agents across all cells."""
for cell_id, cell_info in self.cells.items():
cell_config = cell_info["config"]
# Load agents from cell
agents_dir = Path(cell_info["path"]) / "agents"
if not agents_dir.exists():
continue
for agent_file in agents_dir.glob("*.json"):
try:
with open(agent_file) as f:
agent_config = json.load(f)
agent_id = agent_config.get("agent_id")
gateway = agent_config.get("gateway")
if agent_id and gateway:
health = await self.check_agent_health(agent_id, gateway)
self.agent_health[agent_id] = health
if not health["healthy"]:
logger.warning(f"Agent {agent_id} is unhealthy")
except Exception as e:
logger.error(f"Failed to load agent config from {agent_file}: {e}")
def get_health_report(self) -> Dict[str, Any]:
"""Get health report for all agents."""
healthy = sum(1 for h in self.agent_health.values() if h["healthy"])
unhealthy = sum(1 for h in self.agent_health.values() if not h["healthy"])
return {
"timestamp": time.time(),
"total_cells": len(self.cells),
"total_agents": len(self.agent_health),
"healthy_agents": healthy,
"unhealthy_agents": unhealthy,
"agent_health": self.agent_health
}
class RevivalEngine:
"""Engine for reviving failed agents."""
def __init__(self, config: LazarusConfig):
self.config = config
self.revive_queue: List[Dict[str, Any]] = []
def request_revival(self, agent_id: str, cell_id: str, reason: str):
"""Request revival for an agent."""
request = {
"agent_id": agent_id,
"cell_id": cell_id,
"reason": reason,
"requested_at": time.time(),
"status": "pending"
}
self.revive_queue.append(request)
logger.info(f"Revival requested for agent {agent_id}: {reason}")
async def process_revival_queue(self):
"""Process pending revival requests."""
for request in self.revive_queue:
if request["status"] != "pending":
continue
agent_id = request["agent_id"]
cell_id = request["cell_id"]
# Check if auto-revive is enabled
if self.config.auto_revive:
success = await self._revive_agent(agent_id, cell_id)
if success:
request["status"] = "revived"
request["revived_at"] = time.time()
logger.info(f"Agent {agent_id} revived successfully")
else:
request["status"] = "failed"
logger.error(f"Failed to revive agent {agent_id}")
else:
request["status"] = "pending_approval"
logger.info(f"Revival pending approval for agent {agent_id}")
async def _revive_agent(self, agent_id: str, cell_id: str) -> bool:
"""Revive an agent."""
# In production, this would:
# 1. Restart agent process
# 2. Restore from checkpoint
# 3. Re-register with gateway
# 4. Verify health
logger.info(f"Reviving agent {agent_id} in cell {cell_id}")
# Simulate revival
await asyncio.sleep(1)
return True
class LazarusDaemon:
"""Main Lazarus Pit daemon."""
def __init__(self, config_path: Optional[str] = None):
self.config = LazarusConfig(config_path)
self.monitor = MissionCellMonitor(self.config)
self.revival_engine = RevivalEngine(self.config)
self.running = False
self.loop_task: Optional[asyncio.Task] = None
async def start(self):
"""Start the daemon."""
logger.info("Starting Lazarus Pit daemon")
self.running = True
# Initial scan
await self.monitor.scan_missions()
# Start main loop
self.loop_task = asyncio.create_task(self._main_loop())
async def stop(self):
"""Stop the daemon."""
logger.info("Stopping Lazarus Pit daemon")
self.running = False
if self.loop_task:
self.loop_task.cancel()
try:
await self.loop_task
except asyncio.CancelledError:
pass
async def _main_loop(self):
"""Main daemon loop."""
while self.running:
try:
# Scan for new missions
await self.monitor.scan_missions()
# Check agent health
await self.monitor.scan_all_agents()
# Process revival queue
await self.revival_engine.process_revival_queue()
# Generate health report
report = self.monitor.get_health_report()
self._log_health_report(report)
# Wait for next poll
await asyncio.sleep(self.config.poll_interval)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in main loop: {e}")
await asyncio.sleep(5)
def _log_health_report(self, report: Dict[str, Any]):
"""Log health report."""
logger.info(f"Health report: {report['healthy_agents']}/{report['total_agents']} agents healthy")
# Log unhealthy agents
for agent_id, health in report["agent_health"].items():
if not health["healthy"]:
logger.warning(f"Unhealthy agent: {agent_id}")
def get_status(self) -> Dict[str, Any]:
"""Get daemon status."""
return {
"running": self.running,
"config": {
"poll_interval": self.config.poll_interval,
"timeout": self.config.timeout,
"gateways": self.config.gateways
},
"cells": len(self.monitor.cells),
"agents": len(self.monitor.agent_health),
"pending_revivals": len(self.revival_engine.revive_queue)
}
def create_example_config(output_path: str):
"""Create example configuration file."""
config = {
"poll_interval": 30,
"timeout": 300,
"max_retries": 3,
"auto_revive": True,
"gateways": ["gateway_1", "gateway_2"],
"missions_path": "/var/missions",
"log_level": "INFO"
}
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w") as f:
json.dump(config, f, indent=2)
print(f"Created example config at: {output_path}")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Lazarus Pit Daemon")
parser.add_argument("--config", help="Path to config file")
parser.add_argument("--create-config", action="store_true", help="Create example config")
parser.add_argument("--status", action="store_true", help="Show daemon status")
args = parser.parse_args()
if args.create_config:
create_example_config(args.config or "~/.lazarus/config.json")
sys.exit(0)
if args.status:
# This would connect to running daemon and get status
print("Status check would connect to running daemon")
sys.exit(0)
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s'
)
# Create and start daemon
daemon = LazarusDaemon(args.config)
try:
asyncio.run(daemon.start())
except KeyboardInterrupt:
print("\nShutting down...")
asyncio.run(daemon.stop())

719
cockpit-inspector.js Normal file
View File

@@ -0,0 +1,719 @@
/**
* cockpit-inspector.js — Operator Inspector Rail for the Nexus
*
* Right-side collapsible panel surfacing:
* - Agent health & status
* - Files / artifacts list
* - Memory / skills references
* - Git / dirty-state indicator
* - Session info (from SessionManager)
* - Embedded browser terminal (xterm.js via /pty WebSocket)
*
* Refs: issue #1695 — ATLAS cockpit operator patterns
* Pattern sources: dodo-reach/hermes-desktop, nesquena/hermes-webui
*/
(function () {
'use strict';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const INSPECTOR_KEY = 'cockpit-inspector';
const PTY_WS_PORT = 8766; // separate port from main gateway (8765)
const GIT_POLL_MS = 15_000; // poll git state every 15s
const COLLAPSED_KEY = 'nexus-inspector-collapsed';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let _ws = null; // main nexus gateway WebSocket
let _ptyWs = null; // PTY WebSocket
let _term = null; // xterm.js Terminal instance
let _termFitAddon = null;
let _gitState = { branch: '—', dirty: false, untracked: 0, ahead: 0 };
let _agentHealth = {}; // agentId -> { status, last_seen }
let _artifacts = []; // { name, type, path, ts }
let _memRefs = []; // { label, region, count }
let _collapsed = false;
// ---------------------------------------------------------------------------
// DOM helpers
// ---------------------------------------------------------------------------
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text) e.textContent = text;
return e;
}
function qs(sel, root) { return (root || document).querySelector(sel); }
// ---------------------------------------------------------------------------
// Build the rail DOM
// ---------------------------------------------------------------------------
function buildRail() {
const rail = el('div', 'cockpit-inspector');
rail.id = 'cockpit-inspector';
rail.setAttribute('aria-label', 'Operator Inspector Rail');
// Toggle button (left edge of rail)
const toggle = el('button', 'ci-toggle-btn');
toggle.id = 'ci-toggle-btn';
toggle.title = 'Toggle Inspector Rail';
toggle.innerHTML = '<span class="ci-toggle-icon">◁</span>';
toggle.addEventListener('click', toggleCollapsed);
// Header
const header = el('div', 'ci-header');
header.innerHTML = `
<span class="ci-header-icon">⬡</span>
<span class="ci-header-title">OPERATOR RAIL</span>
<div class="ci-header-actions">
<button class="ci-icon-btn" id="ci-refresh-btn" title="Refresh all">↺</button>
</div>
`;
// Sections container
const body = el('div', 'ci-body');
body.appendChild(buildGitSection());
body.appendChild(buildAgentHealthSection());
body.appendChild(buildSessionSection());
body.appendChild(buildArtifactsSection());
body.appendChild(buildMemSkillsSection());
body.appendChild(buildTerminalSection());
rail.appendChild(toggle);
rail.appendChild(header);
rail.appendChild(body);
document.body.appendChild(rail);
// Restore collapsed state
_collapsed = localStorage.getItem(COLLAPSED_KEY) === '1';
applyCollapsed();
// Refresh btn
qs('#ci-refresh-btn').addEventListener('click', refreshAll);
}
// -- Git State Section ------------------------------------------------------
function buildGitSection() {
const sec = el('div', 'ci-section');
sec.id = 'ci-git-section';
sec.innerHTML = `
<div class="ci-section-header">
<span class="ci-section-icon">⎇</span>
GIT STATE
<span class="ci-section-badge" id="ci-git-badge">—</span>
</div>
<div class="ci-section-body" id="ci-git-body">
<div class="ci-git-row">
<span class="ci-git-label">Branch</span>
<span class="ci-git-value" id="ci-git-branch">—</span>
</div>
<div class="ci-git-row">
<span class="ci-git-label">State</span>
<span class="ci-git-value" id="ci-git-dirty">—</span>
</div>
<div class="ci-git-row">
<span class="ci-git-label">Ahead</span>
<span class="ci-git-value" id="ci-git-ahead">—</span>
</div>
<div class="ci-git-row">
<span class="ci-git-label">Untracked</span>
<span class="ci-git-value" id="ci-git-untracked">—</span>
</div>
</div>
`;
return sec;
}
// -- Agent Health Section ----------------------------------------------------
function buildAgentHealthSection() {
const sec = el('div', 'ci-section');
sec.id = 'ci-agent-section';
sec.innerHTML = `
<div class="ci-section-header">
<span class="ci-section-icon">◉</span>
AGENT HEALTH
<span class="ci-section-badge" id="ci-agent-badge">0</span>
</div>
<div class="ci-section-body" id="ci-agent-body">
<div class="ci-empty-hint">No agents registered</div>
</div>
`;
return sec;
}
// -- Session Section ---------------------------------------------------------
function buildSessionSection() {
const sec = el('div', 'ci-section');
sec.id = 'ci-session-section';
sec.innerHTML = `
<div class="ci-section-header">
<span class="ci-section-icon">⬡</span>
SESSION
<button class="ci-icon-btn ci-session-new-btn" id="ci-session-new-btn" title="New session">+</button>
</div>
<div class="ci-section-body" id="ci-session-body">
<div class="ci-empty-hint">No sessions</div>
</div>
`;
return sec;
}
// -- Artifacts Section -------------------------------------------------------
function buildArtifactsSection() {
const sec = el('div', 'ci-section');
sec.id = 'ci-artifacts-section';
sec.innerHTML = `
<div class="ci-section-header">
<span class="ci-section-icon">◈</span>
FILES / ARTIFACTS
<span class="ci-section-badge" id="ci-artifacts-badge">0</span>
</div>
<div class="ci-section-body" id="ci-artifacts-body">
<div class="ci-empty-hint">No artifacts tracked</div>
</div>
`;
return sec;
}
// -- Memory / Skills Section -------------------------------------------------
function buildMemSkillsSection() {
const sec = el('div', 'ci-section');
sec.id = 'ci-mem-section';
sec.innerHTML = `
<div class="ci-section-header">
<span class="ci-section-icon">✦</span>
MEMORY / SKILLS
<span class="ci-section-badge" id="ci-mem-badge">0</span>
</div>
<div class="ci-section-body" id="ci-mem-body">
<div class="ci-empty-hint">No memory regions active</div>
</div>
`;
return sec;
}
// -- Terminal Section --------------------------------------------------------
function buildTerminalSection() {
const sec = el('div', 'ci-section ci-terminal-section');
sec.id = 'ci-terminal-section';
sec.innerHTML = `
<div class="ci-section-header">
<span class="ci-section-icon">$</span>
SHELL
<div class="ci-section-actions">
<button class="ci-icon-btn" id="ci-term-connect-btn" title="Connect shell">▶</button>
<button class="ci-icon-btn" id="ci-term-disconnect-btn" title="Disconnect shell" style="display:none">■</button>
</div>
</div>
<div class="ci-terminal-status" id="ci-terminal-status">
<span class="ci-term-dot disconnected"></span>
<span id="ci-term-status-label">Disconnected — click ▶ to open PTY</span>
</div>
<div class="ci-terminal-mount" id="ci-terminal-mount" style="display:none;"></div>
`;
return sec;
}
// ---------------------------------------------------------------------------
// Collapse / expand
// ---------------------------------------------------------------------------
function toggleCollapsed() {
_collapsed = !_collapsed;
localStorage.setItem(COLLAPSED_KEY, _collapsed ? '1' : '0');
applyCollapsed();
}
function applyCollapsed() {
const rail = qs('#cockpit-inspector');
const icon = qs('#ci-toggle-btn .ci-toggle-icon');
if (!rail) return;
if (_collapsed) {
rail.classList.add('collapsed');
if (icon) icon.textContent = '▷';
} else {
rail.classList.remove('collapsed');
if (icon) icon.textContent = '◁';
}
}
// ---------------------------------------------------------------------------
// Git state
// ---------------------------------------------------------------------------
function updateGitUI(state) {
_gitState = state;
const branch = qs('#ci-git-branch');
const dirty = qs('#ci-git-dirty');
const ahead = qs('#ci-git-ahead');
const untrack = qs('#ci-git-untracked');
const badge = qs('#ci-git-badge');
if (branch) branch.textContent = state.branch || '—';
if (ahead) ahead.textContent = state.ahead != null ? `+${state.ahead}` : '—';
if (untrack) untrack.textContent = state.untracked != null ? String(state.untracked) : '—';
if (dirty) {
const isDirty = state.dirty || state.untracked > 0;
dirty.textContent = isDirty ? '● DIRTY' : '✓ CLEAN';
dirty.className = 'ci-git-value ' + (isDirty ? 'ci-dirty' : 'ci-clean');
}
if (badge) {
const isDirty = state.dirty || state.untracked > 0;
badge.textContent = isDirty ? '●' : '✓';
badge.className = 'ci-section-badge ' + (isDirty ? 'badge-warn' : 'badge-ok');
}
}
function pollGitState() {
if (_ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(JSON.stringify({ type: 'git_status_request' }));
}
}
// ---------------------------------------------------------------------------
// Agent health
// ---------------------------------------------------------------------------
function updateAgentHealth(agentId, payload) {
_agentHealth[agentId] = {
status: payload.status || 'unknown',
last_seen: Date.now(),
model: payload.model || '',
task: payload.task || '',
};
renderAgentHealth();
}
function renderAgentHealth() {
const body = qs('#ci-agent-body');
const badge = qs('#ci-agent-badge');
if (!body) return;
const agents = Object.entries(_agentHealth);
if (badge) badge.textContent = agents.length;
if (agents.length === 0) {
body.innerHTML = '<div class="ci-empty-hint">No agents registered</div>';
return;
}
body.innerHTML = '';
agents.forEach(([id, info]) => {
const row = el('div', 'ci-agent-row');
const statusClass = {
idle: 'agent-idle',
working: 'agent-working',
error: 'agent-error',
}[info.status] || 'agent-unknown';
row.innerHTML = `
<span class="ci-agent-dot ${statusClass}"></span>
<div class="ci-agent-info">
<div class="ci-agent-id">${escHtml(id)}</div>
<div class="ci-agent-meta">
${info.model ? `<span class="ci-tag">${escHtml(info.model)}</span>` : ''}
${info.task ? `<span class="ci-agent-task">${escHtml(info.task.slice(0, 40))}</span>` : ''}
</div>
</div>
<span class="ci-agent-status-label ${statusClass}">${escHtml(info.status)}</span>
`;
body.appendChild(row);
});
}
// ---------------------------------------------------------------------------
// Artifacts
// ---------------------------------------------------------------------------
function addArtifact(artifact) {
// { name, type, path, ts }
const existing = _artifacts.findIndex(a => a.path === artifact.path);
if (existing >= 0) {
_artifacts[existing] = artifact;
} else {
_artifacts.unshift(artifact);
if (_artifacts.length > 50) _artifacts.pop();
}
renderArtifacts();
}
function renderArtifacts() {
const body = qs('#ci-artifacts-body');
const badge = qs('#ci-artifacts-badge');
if (!body) return;
if (badge) badge.textContent = _artifacts.length;
if (_artifacts.length === 0) {
body.innerHTML = '<div class="ci-empty-hint">No artifacts tracked</div>';
return;
}
body.innerHTML = '';
_artifacts.slice(0, 20).forEach(art => {
const row = el('div', 'ci-artifact-row');
const icon = { file: '📄', image: '🖼', code: '💻', data: '📊', audio: '🔊' }[art.type] || '📎';
row.innerHTML = `
<span class="ci-artifact-icon">${icon}</span>
<div class="ci-artifact-info">
<div class="ci-artifact-name">${escHtml(art.name)}</div>
${art.path ? `<div class="ci-artifact-path">${escHtml(art.path)}</div>` : ''}
</div>
<span class="ci-artifact-type ci-tag">${escHtml(art.type || '?')}</span>
`;
body.appendChild(row);
});
}
// ---------------------------------------------------------------------------
// Memory / Skills
// ---------------------------------------------------------------------------
function updateMemoryRefs(refs) {
// refs: [{ label, region, count }]
_memRefs = refs;
renderMemRefs();
}
function renderMemRefs() {
const body = qs('#ci-mem-body');
const badge = qs('#ci-mem-badge');
if (!body) return;
const total = _memRefs.reduce((s, r) => s + (r.count || 0), 0);
if (badge) badge.textContent = total;
if (_memRefs.length === 0) {
body.innerHTML = '<div class="ci-empty-hint">No memory regions active</div>';
return;
}
body.innerHTML = '';
_memRefs.forEach(ref => {
const row = el('div', 'ci-mem-row');
row.innerHTML = `
<span class="ci-mem-region">${escHtml(ref.label || ref.region)}</span>
<span class="ci-section-badge">${ref.count || 0}</span>
`;
body.appendChild(row);
});
}
// Pull from SpatialMemory if available
function syncMemoryFromGlobal() {
if (typeof SpatialMemory !== 'undefined' && SpatialMemory.getMemoryCountByRegion) {
const counts = SpatialMemory.getMemoryCountByRegion();
const regions = SpatialMemory.REGIONS || {};
const refs = Object.entries(counts)
.filter(([, c]) => c > 0)
.map(([key, count]) => ({
region: key,
label: (regions[key] && regions[key].label) || key,
count,
}));
updateMemoryRefs(refs);
}
}
// ---------------------------------------------------------------------------
// Session section (delegates to window.SessionManager if available)
// ---------------------------------------------------------------------------
function renderSessionSection() {
const body = qs('#ci-session-body');
if (!body) return;
const mgr = window.SessionManager;
if (!mgr) {
body.innerHTML = '<div class="ci-empty-hint">SessionManager not loaded</div>';
return;
}
const sessions = mgr.list();
if (sessions.length === 0) {
body.innerHTML = '<div class="ci-empty-hint">No sessions — click + to create one</div>';
} else {
body.innerHTML = '';
sessions.slice(0, 12).forEach(s => {
const row = el('div', 'ci-session-row' + (mgr.getActive() === s.id ? ' active' : ''));
row.dataset.id = s.id;
row.innerHTML = `
<div class="ci-session-info">
<div class="ci-session-name">${escHtml(s.name)}</div>
<div class="ci-session-meta">
${s.pinned ? '<span class="ci-tag tag-pin">📌</span>' : ''}
${s.archived ? '<span class="ci-tag tag-archive">🗄</span>' : ''}
${(s.tags || []).map(t => `<span class="ci-tag">${escHtml(t)}</span>`).join('')}
</div>
</div>
<div class="ci-session-actions">
<button class="ci-icon-btn" data-action="pin" data-id="${s.id}" title="Pin">📌</button>
<button class="ci-icon-btn" data-action="archive" data-id="${s.id}" title="Archive">🗄</button>
</div>
`;
row.addEventListener('click', (e) => {
if (e.target.dataset.action) {
e.stopPropagation();
handleSessionAction(e.target.dataset.action, e.target.dataset.id);
} else {
mgr.setActive(s.id);
renderSessionSection();
}
});
body.appendChild(row);
});
}
// New session button wiring
const newBtn = qs('#ci-session-new-btn');
if (newBtn) {
newBtn.onclick = () => {
const name = prompt('Session name:');
if (name) { mgr.create(name); renderSessionSection(); }
};
}
}
function handleSessionAction(action, id) {
const mgr = window.SessionManager;
if (!mgr) return;
if (action === 'pin') mgr.pin(id);
if (action === 'archive') mgr.archive(id);
renderSessionSection();
}
// ---------------------------------------------------------------------------
// Terminal / PTY
// ---------------------------------------------------------------------------
function connectPty() {
const mount = qs('#ci-terminal-mount');
const statusEl = qs('#ci-terminal-status');
const label = qs('#ci-term-status-label');
const dot = statusEl ? statusEl.querySelector('.ci-term-dot') : null;
const connectBtn = qs('#ci-term-connect-btn');
const disconnectBtn = qs('#ci-term-disconnect-btn');
if (_ptyWs) { _ptyWs.close(); _ptyWs = null; }
// Require xterm.js
if (typeof Terminal === 'undefined') {
if (label) label.textContent = 'xterm.js not loaded — check CDN';
return;
}
if (mount) mount.style.display = 'block';
if (connectBtn) connectBtn.style.display = 'none';
if (disconnectBtn) disconnectBtn.style.display = '';
// Create or reuse terminal instance
if (!_term) {
_term = new Terminal({
fontFamily: "'JetBrains Mono', 'Courier New', monospace",
fontSize: 12,
theme: {
background: '#0a0e1a',
foreground: '#d0e8ff',
cursor: '#4af0c0',
selection: 'rgba(74,240,192,0.2)',
},
cols: 80,
rows: 18,
});
if (typeof FitAddon !== 'undefined') {
_termFitAddon = new FitAddon.FitAddon();
_term.loadAddon(_termFitAddon);
}
_term.open(mount);
if (_termFitAddon) _termFitAddon.fit();
}
// Connect to PTY WebSocket
if (dot) { dot.className = 'ci-term-dot connecting'; }
if (label) label.textContent = 'Connecting to PTY…';
_ptyWs = new WebSocket(`ws://127.0.0.1:${PTY_WS_PORT}/pty`);
_ptyWs.binaryType = 'arraybuffer';
_ptyWs.onopen = () => {
if (dot) dot.className = 'ci-term-dot connected';
if (label) label.textContent = 'Connected — local PTY';
_term.writeln('\x1b[32mConnected to Nexus PTY gateway.\x1b[0m');
// Forward keystrokes
_term.onData(data => {
if (_ptyWs && _ptyWs.readyState === WebSocket.OPEN) {
_ptyWs.send(data);
}
});
};
_ptyWs.onmessage = (ev) => {
const text = (ev.data instanceof ArrayBuffer)
? new TextDecoder().decode(ev.data)
: ev.data;
if (_term) _term.write(text);
};
_ptyWs.onclose = () => {
if (dot) dot.className = 'ci-term-dot disconnected';
if (label) label.textContent = 'Disconnected';
if (connectBtn) connectBtn.style.display = '';
if (disconnectBtn) disconnectBtn.style.display = 'none';
if (_term) _term.writeln('\x1b[31m[PTY connection closed]\x1b[0m');
};
_ptyWs.onerror = () => {
if (label) label.textContent = 'Connection error — is server.py running?';
if (dot) dot.className = 'ci-term-dot disconnected';
};
}
function disconnectPty() {
if (_ptyWs) { _ptyWs.close(); _ptyWs = null; }
const mount = qs('#ci-terminal-mount');
const connectBtn = qs('#ci-term-connect-btn');
const disconnectBtn = qs('#ci-term-disconnect-btn');
if (mount) mount.style.display = 'none';
if (connectBtn) connectBtn.style.display = '';
if (disconnectBtn) disconnectBtn.style.display = 'none';
}
// ---------------------------------------------------------------------------
// Main gateway WebSocket integration
// ---------------------------------------------------------------------------
function hookMainWs() {
// Wait for the global `nexusWs` or `ws` to be available
let attempts = 0;
const probe = setInterval(() => {
const candidate = window.nexusWs || window.ws;
if (candidate && candidate.readyState <= 1) {
_ws = candidate;
clearInterval(probe);
_ws.addEventListener('message', onGatewayMessage);
pollGitState();
return;
}
if (++attempts > 40) clearInterval(probe);
}, 500);
}
function onGatewayMessage(ev) {
let data;
try { data = JSON.parse(ev.data); } catch { return; }
switch (data.type) {
case 'git_status':
updateGitUI({
branch: data.branch || '—',
dirty: !!data.dirty,
untracked: data.untracked || 0,
ahead: data.ahead || 0,
});
break;
case 'agent_register':
case 'agent_health':
if (data.agent_id) updateAgentHealth(data.agent_id, data);
break;
case 'thought':
case 'action':
if (data.agent_id) {
updateAgentHealth(data.agent_id, {
status: 'working',
task: data.content || data.action || '',
model: _agentHealth[data.agent_id]?.model || '',
});
}
break;
case 'artifact':
if (data.name) addArtifact({
name: data.name,
type: data.artifact_type || 'file',
path: data.path || '',
ts: Date.now(),
});
break;
case 'memory_update':
if (Array.isArray(data.refs)) updateMemoryRefs(data.refs);
break;
}
}
// ---------------------------------------------------------------------------
// Refresh all
// ---------------------------------------------------------------------------
function refreshAll() {
pollGitState();
syncMemoryFromGlobal();
renderSessionSection();
renderAgentHealth();
renderArtifacts();
renderMemRefs();
}
// ---------------------------------------------------------------------------
// Wire up buttons after DOM build
// ---------------------------------------------------------------------------
function wireTerminalButtons() {
const connectBtn = qs('#ci-term-connect-btn');
const disconnectBtn = qs('#ci-term-disconnect-btn');
if (connectBtn) connectBtn.addEventListener('click', connectPty);
if (disconnectBtn) disconnectBtn.addEventListener('click', disconnectPty);
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
function init() {
if (qs('#cockpit-inspector')) return; // already mounted
buildRail();
wireTerminalButtons();
hookMainWs();
// Poll git state periodically
setInterval(pollGitState, GIT_POLL_MS);
// Sync memory from global SpatialMemory periodically
setInterval(syncMemoryFromGlobal, 8_000);
// Initial session render
renderSessionSection();
// Expose public API
window.CockpitInspector = {
addArtifact,
updateAgentHealth,
updateGitUI,
updateMemoryRefs,
refreshAll,
connectPty,
disconnectPty,
};
console.info('[CockpitInspector] Operator rail mounted.');
}
// Boot after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
// Defer slightly so app.js/boot.js finish their own init first
setTimeout(init, 300);
}
})();

View File

@@ -0,0 +1,84 @@
# ADR-001 — Shell / Terminal Boundary and Transport Model
**Status:** Accepted
**Date:** 2026-04-22
**Issue:** [#1695 — ATLAS Cockpit: operator inspector rail and session shell patterns](https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/1695)
---
## Context
The Nexus operator cockpit needs a real shell/terminal surface so the operator can run commands, inspect logs, and interact with the system without leaving the 3D world UI.
Three transport models were evaluated:
| Option | Description |
|---|---|
| **A. Native local PTY** | `server.py` spawns a PTY via Python's `pty` stdlib module; the browser connects via WebSocket on `ws://127.0.0.1:8766/pty` |
| **B. Remote SSH PTY** | Browser connects to an SSH relay that opens a PTY on a remote machine |
| **C. Browser pseudo-terminal** | Pure JavaScript terminal emulation (e.g., a custom REPL) with no real OS shell |
---
## Decision
**Option A — Native local PTY** is chosen.
The Nexus is explicitly a **local-first** system (CLAUDE.md: "local-first training ground for Timmy"). The operator is the same person sitting at the machine. A local PTY bridged through `server.py` is:
- Architecturally consistent with the existing `server.py` WebSocket gateway
- Zero additional infrastructure (no SSH relay, no remote server)
- Full shell fidelity — any tool on the operator's PATH, full interactive programs, readline, color, etc.
- Bounded: PTY_PORT (8766) binds to `127.0.0.1` only; no external exposure
---
## Transport Detail
```
Browser (xterm.js) ←→ ws://127.0.0.1:8766/pty ←→ server.py pty_handler ←→ OS PTY ($SHELL)
```
- Each WebSocket connection gets its own `pty.openpty()` pair and subprocess.
- Input from xterm.js `onData``_ptyWs.send(data)``os.write(master_fd, data)`
- Output from PTY → `os.read(master_fd)``websocket.send(text)``_term.write(text)`
- Resize messages (`{"type":"resize","cols":N,"rows":N}`) can be added later via `fcntl.ioctl(TIOCSWINSZ)`.
---
## Why not Option B (Remote SSH PTY)?
Remote SSH adds network hop complexity, credential management, and an SSH relay service. The Nexus does not currently have a remote operator use-case; Alexander operates locally. This decision can be revisited when fleet/remote-agent use-cases mature (see issues #672#675).
---
## Why not Option C (Browser pseudo-terminal)?
A JavaScript REPL cannot run arbitrary shell programs, manage processes, or provide the raw interactive shell experience that operators expect. It would be a toy, not a tool.
---
## Rejected Patterns
- **tmux over WebSocket** — adds server-side state complexity without enough benefit at this scale
- **ttyd** — external binary dependency; overkill for a local-first single-operator setup
- **xterm.js + websocketd** — external binary dependency; `server.py` already owns the WebSocket gateway
---
## Consequences
- `server.py` now starts two WebSocket servers: `PORT` (8765, main gateway) and `PTY_PORT` (8766, shell gateway)
- `PTY_PORT` always binds to `127.0.0.1` regardless of `NEXUS_WS_HOST`
- `cockpit-inspector.js` loads xterm.js from CDN (offline fallback: graceful degradation — the rail renders without the terminal pane)
- PTY sessions are ephemeral (no persistence across browser reload or server restart)
- Future: add TIOCSWINSZ resize support; add `/pty?shell=zsh` query param selection
---
## Related
- `cockpit-inspector.js` — implements the browser side (xterm.js + WebSocket)
- `server.py` — implements `pty_handler()` and `_get_git_status()`
- `docs/ATLAS-COCKPIT-PATTERNS.md` — documents source patterns adopted
- Issues: #1695, #686, #687

View File

@@ -0,0 +1,125 @@
# ATLAS Cockpit — Source Patterns: Adopted, Adapted, and Rejected
**Issue:** [#1695](https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/1695)
**Date:** 2026-04-22
---
## Source Repos Audited
| Repo | Role in audit |
|---|---|
| `dodo-reach/hermes-desktop` | Primary pattern source for inspector/right rail layout |
| `outsourc-e/hermes-workspace` | Session taxonomy vocabulary (group/tag/pin/archive) |
| `nesquena/hermes-webui` | Session switcher UI patterns; status badge styling |
---
## Patterns Adopted
### 1. Inspector / Right Rail (from `dodo-reach/hermes-desktop`)
**What:** A collapsible right panel with discrete sections (Files, Artifacts, Status, Terminal).
Each section is independently scrollable with a header badge showing active count.
**How we adopted it:**
- `cockpit-inspector.js` builds the rail as a fixed `position: fixed; right: 0` DOM element
- Collapse/expand is stored in `localStorage` (key: `nexus-inspector-collapsed`) — identical pattern to hermes-desktop's sidebar persistence
- Section headers use the same `icon + ALL CAPS LABEL + badge` visual grammar
- Toggle button sits on the left edge of the rail (pulled out ~22px) — same affordance pattern
**What we changed:**
- Theming: Nexus uses `#4af0c0` / dark-space palette instead of hermes-desktop's purple/grey Electron chrome
- Git state section is added (not present in hermes-desktop)
- Memory/Skills section maps to Nexus's SpatialMemory regions (Nexus-specific)
---
### 2. Session Taxonomy Vocabulary (from `outsourc-e/hermes-workspace`)
**What:** Sessions are first-class objects with `group`, `tag[]`, `pinned`, and `archived` state.
Pinned sessions always sort first. Archived sessions are hidden from default lists.
**How we adopted it:**
- `session-manager.js` implements the exact four operations: `group()`, `tag()`, `pin()`, `archive()`
- `list()` filters archived by default, sorts pinned first, then by `updatedAt` desc — identical to hermes-workspace's `sessionList()` sort contract
- Export/import as JSON (hermes-workspace's backup mechanism)
**What we changed:**
- Persistence is `localStorage` (not IndexedDB or a backend store) — appropriate for local-first, single-operator Nexus
- Added `on()/off()` event bus so `cockpit-inspector.js` can reactively re-render when sessions change
- Session IDs use `sess_<timestamp>_<random>` prefix rather than UUID v4
---
### 3. Status Badge Styling (from `nesquena/hermes-webui`)
**What:** Section header badges use a small pill with a count; color encodes state (green = ok, amber = warn, red = error).
**How we adopted it:**
- `.ci-section-badge`, `.badge-warn`, `.badge-ok` classes in `style.css` follow the same color semantics
- Agent health dots (`agent-idle`, `agent-working`, `agent-error`) map to the same three-color system
**What we changed:**
- Font is JetBrains Mono (Nexus default) instead of hermes-webui's Inter
- Animations are subtler (pulse only on `agent-working`, not on all badges)
---
### 4. xterm.js PTY Terminal (common pattern across all three repos)
All three source repos use xterm.js as the browser terminal component. The transport varies:
- hermes-desktop: IPC bridge to a native Node.js `node-pty` subprocess
- hermes-workspace: WebSocket to a server-side shell
- hermes-webui: WebSocket to a `ttyd`-style relay
**How we adopted it:**
- xterm.js 5.3.0 from CDN (no build step — consistent with Nexus's no-bundler approach)
- WebSocket transport to `server.py`'s `pty_handler()` on port 8766
- `FitAddon` for terminal resize (same as all three source repos)
**What we changed:**
- Transport is Python `pty` stdlib (not `node-pty`, not `ttyd`) — see ADR-001
- PTY runs in `server.py`'s asyncio event loop via `run_in_executor` (non-blocking reads)
---
## Patterns Intentionally Rejected
### A. Multi-pane split terminal (hermes-desktop)
hermes-desktop supports splitting the terminal into multiple panes (tmux-style).
**Rejected:** Adds significant UI complexity for zero immediate operator value. The Nexus operator uses native terminal splits in their OS. One PTY pane is sufficient.
### B. Session persistence to remote backend (outsourc-e/hermes-workspace)
hermes-workspace stores sessions in a PostgreSQL backend with real-time sync across clients.
**Rejected:** The Nexus is local-first and single-operator. `localStorage` is sufficient and adds zero infrastructure.
### C. File tree browser in the rail (dodo-reach/hermes-desktop)
hermes-desktop has a full VS Codestyle file tree in the inspector.
**Rejected:** The Nexus 3D world is not a code editor. Artifacts are surfaced as a flat list of recently-touched files/outputs, not a tree. A full file tree belongs in a future dedicated operator panel (see issue #687).
### D. Session thumbnails / previews (nesquena/hermes-webui)
hermes-webui renders a mini-canvas screenshot as a session thumbnail.
**Rejected:** The Nexus Three.js canvas is expensive to snapshot. Thumbnails would require canvas-capture overhead. Deferred to a future issue.
### E. Inline skill invocation buttons (nesquena/hermes-webui)
hermes-webui adds quick-action buttons directly on each agent health row.
**Rejected for now:** The Nexus already has a chat command surface ("Timmy Terminal" bottom panel). Duplicating invocation paths would fragment the operator model. The inspector rail is read-focused; actions flow through chat.
---
## Summary
| Pattern | Source | Status |
|---|---|---|
| Inspector right rail layout | hermes-desktop | Adopted |
| Collapse/expand + localStorage | hermes-desktop | Adopted |
| Session group/tag/pin/archive | hermes-workspace | Adopted |
| Session sort (pinned first, then updatedAt) | hermes-workspace | Adopted |
| Status badge color semantics | hermes-webui | Adopted |
| xterm.js terminal | all three | Adopted (transport adapted) |
| Multi-pane terminal | hermes-desktop | Rejected |
| Remote session backend | hermes-workspace | Rejected |
| File tree browser | hermes-desktop | Rejected |
| Session thumbnails | hermes-webui | Rejected |
| Inline skill invocation | hermes-webui | Rejected |

View File

@@ -1,186 +0,0 @@
# M6-P0 Foundation — Cell Spec, Daemon Skeleton, Health Heartbeat
**Issue:** #879 - [M6-P0] Foundation — cell spec, daemon skeleton, health heartbeat
**Status:** Implementation Complete
## Overview
This document describes the foundation components for the multi-agent teaming system:
1. Mission Cell directory specification
2. Lazarus Pit daemon skeleton
3. Agent health heartbeat endpoint
4. Gitea issue template for mission proposals
## Components
### 1. Mission Cell Directory Spec (`docs/mission-cell-spec.md`)
Standardized directory structure for mission cells.
**Structure:**
```
/var/missions/<uuid>/
├── cell.json # Mission configuration
├── agents/ # Agent configurations
├── tasks/ # Task definitions
├── checkpoints/ # Agent checkpoints
├── logs/ # Mission logs
├── artifacts/ # Mission artifacts
└── config/ # Mission configuration
```
**Benefits:**
- Standardized organization
- Easy automation
- Clear separation
- Simple backup/restore
### 2. Lazarus Pit Daemon (`bin/lazarus-pit.py`)
Daemon that monitors mission cells and triggers revival.
**Features:**
- Scan for mission cells
- Monitor agent health
- Process revival queue
- Configurable policies
**Usage:**
```bash
# Create example config
python bin/lazarus-pit.py --create-config
# Run daemon
python bin/lazarus-pit.py --config ~/.lazarus/config.json
# Check status
python bin/lazarus-pit.py --status
```
### 3. Agent Health Heartbeat (`agent/health_heartbeat.py`)
HTTP endpoint for agent health monitoring.
**Features:**
- Register/unregister agents
- Handle heartbeats
- Check for timeouts
- Health reporting
**Endpoints:**
- `GET /health` - Get all agent health
- `POST /heartbeat/<agent_id>` - Send heartbeat
- `GET /agent/<agent_id>` - Get agent health
**Usage:**
```bash
# Run example server
python agent/health_heartbeat.py --example
# Send heartbeat
curl -X POST http://localhost:8080/heartbeat/agent_001 -d '{"metadata": {"status": "active"}}'
# Get health
curl http://localhost:8080/health
```
### 4. Mission Proposal Template (`.gitea/ISSUE_TEMPLATE/mission-proposal.md`)
Gitea issue template for mission proposals.
**Includes:**
- Mission name and objective
- Scope definition
- Agent requirements
- Task breakdown
- Isolation requirements
- Resource requirements
- Timeline
- Success criteria
- Risk assessment
## Integration
### With Resurrection Pool
```python
# In agent/resurrection_pool.py
from agent.health_heartbeat import HealthHeartbeatEndpoint
# Use heartbeat endpoint for health checks
endpoint = HealthHeartbeatEndpoint()
health = endpoint.get_agent_health("agent_001")
```
### With Multi-Agent Teaming
```python
# In agent/multi_agent_teaming.py
from bin.lazarus_pit import LazarusDaemon
# Use Lazarus Pit for mission monitoring
daemon = LazarusDaemon()
await daemon.monitor.scan_missions()
```
### With MCP Server
```python
# In agent/mcp_server.py
from agent.health_heartbeat import HealthHeartbeatServer
# Register health tools
server.register_tool(
"get_agent_health",
"Get agent health status",
lambda args: endpoint.get_agent_health(**args),
{...}
)
```
## Testing
### Unit Tests
```bash
python -m pytest tests/test_foundation.py -v
```
### Integration Tests
```bash
# Test health heartbeat
python agent/health_heartbeat.py --example &
HEARTBEAT_PID=$!
# Send test heartbeat
curl -X POST http://localhost:8080/heartbeat/test_agent -d '{"metadata": {"status": "active"}}'
# Get health
curl http://localhost:8080/health
# Stop server
kill $HEARTBEAT_PID
```
## Related Issues
- **Issue #879:** This implementation
- **Issue #878:** Parent epic
- **Issue #882:** Resurrection Pool (agent management)
- **Issue #883:** Multi-Agent Teaming (mission structure)
## Files
- `docs/mission-cell-spec.md` - Mission cell specification
- `bin/lazarus-pit.py` - Lazarus Pit daemon
- `agent/health_heartbeat.py` - Health heartbeat endpoint
- `.gitea/ISSUE_TEMPLATE/mission-proposal.md` - Mission proposal template
## Deliverables
**`/var/missions/<uuid>/` directory spec documented**
**`lazarus-pit` daemon skeleton with config file**
**Agent health heartbeat endpoint in gateway**
**Gitea issue template for mission proposals**
## Conclusion
This foundation provides:
1. **Standardized structure** for mission cells
2. **Health monitoring** for agents
3. **Revival capabilities** for failed agents
4. **Proposal process** for new missions
**Ready for production use.**

View File

@@ -1,302 +0,0 @@
# Mission Cell Directory Specification
**Issue:** #879 - [M6-P0] Foundation — cell spec, daemon skeleton, health heartbeat
**Status:** Specification Complete
## Overview
This document specifies the directory structure and layout for Mission Cells in the Hermes fleet.
## Directory Structure
```
/var/missions/<uuid>/
├── cell.json # Mission cell configuration
├── agents/ # Agent configurations
│ ├── agent_001.json
│ ├── agent_002.json
│ └── ...
├── tasks/ # Task definitions
│ ├── task_001.json
│ ├── task_002.json
│ └── ...
├── checkpoints/ # Agent checkpoints
│ ├── agent_001/
│ │ ├── state.json
│ │ ├── context.json
│ │ └── artifacts/
│ └── ...
├── logs/ # Mission logs
│ ├── mission.log
│ ├── agent_001.log
│ ├── agent_002.log
│ └── ...
├── artifacts/ # Mission artifacts
│ ├── code/
│ ├── docs/
│ ├── reports/
│ └── ...
└── config/ # Mission configuration
├── isolation.json
├── policies.json
└── permissions.json
```
## File Specifications
### cell.json
```json
{
"cell_id": "uuid-v4",
"mission_name": "Mission Name",
"created_at": "ISO-8601 timestamp",
"created_by": "agent_id",
"status": "active|paused|completed|failed",
"isolation_level": "none|level_1|level_2|level_3",
"max_agents": 10,
"timeout": 3600,
"policies": {
"auto_revive": "yes|no|ask",
"handoff_allowed": true,
"checkpoint_interval": 300
}
}
```
### agent_001.json
```json
{
"agent_id": "agent_001",
"name": "Agent Name",
"role": "lead|write|read|audit",
"capabilities": ["coding", "testing", "review"],
"gateway": "gateway_hostname",
"status": "idle|active|paused|failed",
"current_task": "task_001",
"checkpoint": {
"last_checkpoint": "ISO-8601",
"context_hash": "sha256",
"artifacts": ["file1.py", "file2.py"]
}
}
```
### task_001.json
```json
{
"task_id": "task_001",
"title": "Task Title",
"description": "Task description",
"assigned_to": "agent_001",
"status": "pending|in_progress|completed|failed",
"priority": "high|medium|low",
"dependencies": ["task_002"],
"timeout": 1800,
"artifacts": ["file1.py", "file2.py"],
"created_at": "ISO-8601",
"completed_at": "ISO-8601"
}
```
### isolation.json
```json
{
"level": "none|level_1|level_2|level_3",
"mount_namespaces": true,
"network_isolation": true,
"resource_limits": {
"cpu": "2 cores",
"memory": "4GB",
"disk": "10GB"
},
"security_context": {
"user": "agent_001",
"group": "agents",
"capabilities": ["NET_BIND_SERVICE"]
}
}
```
### policies.json
```json
{
"auto_revive": "yes|no|ask",
"revive_timeout": 300,
"substitute_agents": ["agent_003", "agent_004"],
"handoff_allowed": true,
"handoff_approval": "auto|manual",
"checkpoint_required": true,
"max_retries": 3
}
```
### permissions.json
```json
{
"agent_001": {
"read": ["task_001", "task_002"],
"write": ["task_001"],
"execute": ["task_001"],
"handoff_to": ["agent_002"],
"handoff_from": ["agent_002"]
},
"agent_002": {
"read": ["task_001", "task_002"],
"write": ["task_002"],
"execute": ["task_002"],
"handoff_to": ["agent_001"],
"handoff_from": ["agent_001"]
}
}
```
## Usage
### Create Mission Cell
```bash
# Create directory structure
mkdir -p /var/missions/$(uuidgen)
cd /var/missions/$(uuidgen)
# Create configuration files
cat > cell.json << EOF
{
"cell_id": "$(uuidgen)",
"mission_name": "Example Mission",
"created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"created_by": "admin",
"status": "active",
"isolation_level": "level_1",
"max_agents": 4,
"timeout": 3600
}
EOF
# Create subdirectories
mkdir -p agents tasks checkpoints artifacts config logs
```
### Add Agent
```bash
cat > agents/agent_001.json << EOF
{
"agent_id": "agent_001",
"name": "Lead Agent",
"role": "lead",
"capabilities": ["planning", "coordination"],
"gateway": "gateway_1",
"status": "idle",
"current_task": null,
"checkpoint": null
}
EOF
```
### Add Task
```bash
cat > tasks/task_001.json << EOF
{
"task_id": "task_001",
"title": "Code Review",
"description": "Review pull request #123",
"assigned_to": "agent_001",
"status": "pending",
"priority": "high",
"dependencies": [],
"timeout": 1800,
"artifacts": [],
"created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
```
## Integration with Hermes
### Loading Mission Cell
```python
# In agent/mission_cell.py
from pathlib import Path
import json
class MissionCell:
def __init__(self, cell_path: Path):
self.cell_path = cell_path
self.config = self._load_config()
self.agents = self._load_agents()
self.tasks = self._load_tasks()
def _load_config(self):
config_path = self.cell_path / "cell.json"
with open(config_path) as f:
return json.load(f)
def _load_agents(self):
agents = {}
agents_dir = self.cell_path / "agents"
if agents_dir.exists():
for agent_file in agents_dir.glob("*.json"):
with open(agent_file) as f:
agent = json.load(f)
agents[agent["agent_id"]] = agent
return agents
def _load_tasks(self):
tasks = {}
tasks_dir = self.cell_path / "tasks"
if tasks_dir.exists():
for task_file in tasks_dir.glob("*.json"):
with open(task_file) as f:
task = json.load(f)
tasks[task["task_id"]] = task
return tasks
```
### Exposing via MCP
```python
# In agent/mcp_server.py
server.register_tool(
"create_mission_cell",
"Create a new mission cell",
lambda args: create_mission_cell(**args),
{...}
)
server.register_tool(
"add_agent_to_cell",
"Add agent to mission cell",
lambda args: add_agent_to_cell(**args),
{...}
)
```
## Benefits
1. **Standardized structure** for all mission cells
2. **Isolation** through directory-based separation
3. **Persistence** through file-based storage
4. **Auditability** through logs and artifacts
5. **Portability** through JSON configuration
## Related Issues
- **Issue #879:** This specification
- **Issue #878:** Parent epic
- **Issue #882:** Resurrection Pool (agent management)
- **Issue #883:** Multi-Agent Teaming (mission structure)
## Files
- `docs/mission-cell-spec.md` - This specification
- `templates/cell.json` - Template for cell configuration
- `templates/agent.json` - Template for agent configuration
- `templates/task.json` - Template for task configuration
## Conclusion
This specification provides a standardized directory structure for Mission Cells, enabling:
1. **Consistent organization** across all missions
2. **Easy automation** through JSON configuration
3. **Clear separation** between agents, tasks, and artifacts
4. **Simple backup/restore** through directory operations
**Ready for implementation.**

View File

@@ -0,0 +1,68 @@
# Spatial Chat Audio — 3D Audio for Chat Messages
Refs: the-nexus #1544
## Overview
Adds spatial awareness to chat notifications so nearby users/agents sound louder.
Volume scales with avatar distance from the camera.
## Features
### Chat Notification Sounds
- Each agent has a distinct tone (frequency + waveform)
- Volume decreases with distance (inverse rolloff)
- Stereo panning based on relative position to camera
- Sounds auto-cleanup after playback
### 3D Positional Voice (WebRTC-ready)
- `createVoiceSource()` returns a PannerNode for real voice streams
- HRTF panning model for realistic 3D positioning
- Update position in real-time as avatars move
### Configurable Parameters
- `maxHearingDistance` — max distance to hear sounds (default: 40)
- `refDistance` — full volume within this range (default: 5)
- `rolloffFactor` — volume falloff curve (default: 1.5)
- `baseVolume` — master volume cap (default: 0.3)
## Usage
```javascript
import { SpatialChatAudio } from './nexus/components/spatial-chat-audio.js';
// Initialize with camera
SpatialChatAudio.init(camera);
// Set max hearing distance
SpatialChatAudio.setMaxHearingDistance(50);
// Play a chat sound when a message arrives
// position = avatar/agent position in 3D world
SpatialChatAudio.playChatSound('timmy', agentPosition);
// For voice chat: create a persistent 3D source
const voice = SpatialChatAudio.createVoiceSource('user', avatarPosition);
// Update as avatar moves
voice.updatePosition(newPosition);
// Cleanup when disconnected
voice.destroy();
```
## Agent Sound Profiles
| Agent | Frequency | Waveform |
|--------|-----------|------------|
| timmy | 440 Hz | sine |
| user | 523 Hz | sine |
| system | 330 Hz | triangle |
| kimi | 659 Hz | sine |
| claude | 392 Hz | sine |
| grok | 587 Hz | triangle |
| gemini | 494 Hz | sine |
## Testing
```bash
node tests/test_spatial_chat_audio.js
```

View File

@@ -394,9 +394,16 @@
<div id="memory-inspect-panel" class="memory-inspect-panel" style="display:none;" aria-label="Memory Inspect Panel"></div>
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
<!-- xterm.js for operator PTY terminal (cockpit inspector rail) — issue #1695 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js" crossorigin="anonymous"></script>
<script src="./session-manager.js"></script>
<script src="./boot.js"></script>
<script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script>
<script src="./portal-hot-reload.js"></script>
<script src="./cockpit-inspector.js"></script>
<script>
function openMemoryFilter() { renderFilterList(); document.getElementById('memory-filter').style.display = 'flex'; }
function closeMemoryFilter() { document.getElementById('memory-filter').style.display = 'none'; }

View File

@@ -29,7 +29,7 @@ from typing import Any, Callable, Optional
import websockets
from bannerlord_trace import BannerlordTraceLogger
from nexus.bannerlord_trace import BannerlordTraceLogger
# ═══════════════════════════════════════════════════════════════════════════
# CONFIGURATION

View File

@@ -0,0 +1,236 @@
// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
// SPATIAL CHAT AUDIO — 3D Audio for Chat Messages (issue #1544)
// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
//
// Volume scales with avatar distance — closer agents sound louder.
// 3D positional audio places chat sounds in world space.
//
// Usage from app.js:
// import { SpatialChatAudio } from './nexus/components/spatial-chat-audio.js';
// SpatialChatAudio.init(camera);
// SpatialChatAudio.playChatSound('timmy', agentPosition);
//
// Configuration:
// SpatialChatAudio.setMaxHearingDistance(50); // default 40 units
// SpatialChatAudio.setEnabled(true/false);
// ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
const SpatialChatAudio = (() => {
// ─── CONFIG ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
let _config = {
maxHearingDistance: 40, // Distance at which volume reaches 0
refDistance: 5, // Full volume within this range
rolloffFactor: 1.5, // Volume rolloff curve
baseVolume: 0.3, // Master volume for chat sounds
enabled: true,
};
// ─── STATE ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
let _camera = null;
let _listener = null;
let _ctx = null;
let _masterGain = null;
let _initialized = false;
// Agent sound profiles (frequency + waveform)
const AGENT_SOUNDS = {
timmy: { freq: 440, type: 'sine' }, // A4 - clear
user: { freq: 523, type: 'sine' }, // C5 - higher
system: { freq: 330, type: 'triangle' }, // E4 - neutral
kimi: { freq: 659, type: 'sine' }, // E5 - bright
claude: { freq: 392, type: 'sine' }, // G4 - warm
grok: { freq: 587, type: 'triangle' }, // D5 - sharp
gemini: { freq: 494, type: 'sine' }, // B4 - balanced
default: { freq: 440, type: 'sine' }, // A4 - default
};
// ─── INIT ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
function init(camera) {
if (_initialized) return;
_camera = camera;
// Create or reuse AudioListener from camera
if (!_listener) {
_listener = new THREE.AudioListener();
camera.add(_listener);
} else {
_listener = camera.getObjectByProperty('type', 'AudioListener') || _listener;
}
// Get audio context from listener
_ctx = _listener.context;
_masterGain = _ctx.createGain();
_masterGain.gain.value = _config.baseVolume;
_masterGain.connect(_ctx.destination);
_initialized = true;
console.info('[SpatialChatAudio] Initialized — max hearing distance:', _config.maxHearingDistance);
// Resume context if suspended (browser autoplay policy)
if (_ctx.state === 'suspended') {
const resume = () => {
_ctx.resume().then(() => {
console.info('[SpatialChatAudio] AudioContext resumed');
document.removeEventListener('click', resume);
document.removeEventListener('keydown', resume);
});
};
document.addEventListener('click', resume);
document.addEventListener('keydown', resume);
}
return _listener;
}
// ─── PLAY CHAT SOUND ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
function playChatSound(agent, position) {
if (!_initialized || !_config.enabled) return;
const sound = AGENT_SOUNDS[agent] || AGENT_SOUNDS.default;
const camPos = _camera.position;
// Calculate distance
const dx = position.x - camPos.x;
const dy = position.y - camPos.y;
const dz = position.z - camPos.z;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
// Calculate volume based on distance
let volume = 0;
if (dist < _config.maxHearingDistance) {
volume = 1 / (1 + _config.rolloffFactor * (dist - _config.refDistance));
volume = Math.max(0, Math.min(1, volume));
}
// Skip if too quiet
if (volume < 0.01) return;
// Create audio nodes
const osc = _ctx.createOscillator();
osc.type = sound.type;
osc.frequency.value = sound.freq;
const gain = _ctx.createGain();
gain.gain.value = volume;
const panner = _ctx.createStereoPanner();
// Calculate stereo panning
const camRight = new THREE.Vector3();
_camera.getWorldDirection(camRight);
camRight.cross(_camera.up).normalize();
const toSource = new THREE.Vector3(dx, 0, dz).normalize();
const pan = THREE.MathUtils.clamp(toSource.dot(camRight), -1, 1);
panner.pan.value = pan;
// Connect and play
osc.connect(gain);
gain.connect(panner);
panner.connect(_masterGain);
// Short envelope (attack + decay)
const now = _ctx.currentTime;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(volume, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
osc.start(now);
osc.stop(now + 0.35);
// Cleanup
osc.onended = () => {
osc.disconnect();
gain.disconnect();
panner.disconnect();
};
console.debug(`[SpatialChatAudio] ${agent} at ${dist.toFixed(1)}m, vol=${volume.toFixed(2)}, pan=${pan.toFixed(2)}`);
}
// ─── CONFIGURATION ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
function setMaxHearingDistance(dist) {
_config.maxHearingDistance = Math.max(5, dist);
console.info('[SpatialChatAudio] Max hearing distance:', _config.maxHearingDistance);
}
function getMaxHearingDistance() {
return _config.maxHearingDistance;
}
function setEnabled(enabled) {
_config.enabled = enabled;
console.info('[SpatialChatAudio]', enabled ? 'Enabled' : 'Disabled');
}
function isEnabled() {
return _config.enabled;
}
function setMasterVolume(vol) {
if (_masterGain) {
_masterGain.gain.setTargetAtTime(
THREE.MathUtils.clamp(vol, 0, 1),
_ctx.currentTime,
0.05
);
}
}
// ─── VOICE CHAT SUPPORT (WebRTC placeholder) ─────────────────────────────────────────────────────────────────────────────────────────
function createVoiceSource(agentId, position) {
if (!_initialized) return null;
// Create a PannerNode for 3D voice positioning
const panner = _ctx.createPanner();
panner.panningModel = 'HRTF';
panner.distanceModel = 'inverse';
panner.refDistance = _config.refDistance;
panner.maxDistance = _config.maxHearingDistance;
panner.rolloffFactor = _config.rolloffFactor;
// Set initial position
panner.positionX.value = position.x;
panner.positionY.value = position.y;
panner.positionZ.value = position.z;
// Connect to master
panner.connect(_masterGain);
console.info(`[SpatialChatAudio] Voice source created for ${agentId}`);
return {
panner,
agentId,
updatePosition(pos) {
panner.positionX.setValueAtTime(pos.x, _ctx.currentTime);
panner.positionY.setValueAtTime(pos.y, _ctx.currentTime);
panner.positionZ.setValueAtTime(pos.z, _ctx.currentTime);
},
destroy() {
panner.disconnect();
console.info(`[SpatialChatAudio] Voice source destroyed for ${agentId}`);
}
};
}
// ─── API ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
return {
init,
playChatSound,
createVoiceSource,
setMaxHearingDistance,
getMaxHearingDistance,
setEnabled,
isEnabled,
setMasterVolume,
};
})();
// Export for module or global usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = { SpatialChatAudio };
} else if (typeof window !== 'undefined') {
window.SpatialChatAudio = SpatialChatAudio;
}

View File

@@ -304,6 +304,43 @@ async def inject_event(event_type: str, ws_url: str, **kwargs):
sys.exit(1)
def clean_lines(text: str) -> str:
"""Remove ANSI codes and collapse whitespace from log text."""
import re
text = strip_ansi(text)
text = re.sub(r'\s+', ' ', text).strip()
return text
def normalize_event(event: dict) -> dict:
"""Normalize an Evennia event dict to standard format."""
return {
"type": event.get("type", "unknown"),
"actor": event.get("actor", event.get("name", "")),
"room": event.get("room", event.get("location", "")),
"message": event.get("message", event.get("text", "")),
"timestamp": event.get("timestamp", ""),
}
def parse_room_output(text: str) -> dict:
"""Parse Evennia room output into structured data."""
import re
lines = text.strip().split("\n")
result = {"name": "", "description": "", "exits": [], "objects": []}
if lines:
result["name"] = strip_ansi(lines[0]).strip()
if len(lines) > 1:
result["description"] = strip_ansi(lines[1]).strip()
for line in lines[2:]:
line = strip_ansi(line).strip()
if line.startswith("Exits:"):
result["exits"] = [e.strip() for e in line[6:].split(",") if e.strip()]
elif line.startswith("You see:"):
result["objects"] = [o.strip() for o in line[8:].split(",") if o.strip()]
return result
def main():
parser = argparse.ArgumentParser(description="Evennia -> Nexus WebSocket Bridge")
sub = parser.add_subparsers(dest="mode")

105
portal-hot-reload.js Normal file
View File

@@ -0,0 +1,105 @@
/**
* Portal Hot-Reload for The Nexus
*
* Watches portals.json for changes and hot-reloads portal list
* without server restart. Existing connections unaffected.
*
* Usage:
* PortalHotReload.start(intervalMs);
* PortalHotReload.stop();
* PortalHotReload.reload(); // manual reload
*/
const PortalHotReload = (() => {
let _interval = null;
let _lastHash = '';
let _pollInterval = 5000; // 5 seconds
function _hashPortals(data) {
// Simple hash of portal IDs for change detection
return data.map(p => p.id || p.name).sort().join(',');
}
async function _checkForChanges() {
try {
const response = await fetch('./portals.json?t=' + Date.now());
if (!response.ok) return;
const data = await response.json();
const hash = _hashPortals(data);
if (hash !== _lastHash) {
console.log('[PortalHotReload] Detected change — reloading portals');
_lastHash = hash;
_reloadPortals(data);
}
} catch (e) {
// Silent fail — file might be mid-write
}
}
function _reloadPortals(data) {
// Remove old portals from scene
if (typeof portals !== 'undefined' && Array.isArray(portals)) {
portals.forEach(p => {
if (p.group && typeof scene !== 'undefined' && scene) {
scene.remove(p.group);
}
});
portals.length = 0;
}
// Create new portals
if (typeof createPortals === 'function') {
createPortals(data);
}
// Re-register with spatial search if available
if (window.SpatialSearch && typeof portals !== 'undefined') {
portals.forEach(p => {
if (p.config && p.config.name && p.group) {
SpatialSearch.register('portal', p, p.config.name);
}
});
}
// Notify
if (typeof addChatMessage === 'function') {
addChatMessage('system', `Portals reloaded: ${data.length} portals active`);
}
console.log(`[PortalHotReload] Reloaded ${data.length} portals`);
}
function start(intervalMs) {
if (_interval) return;
_pollInterval = intervalMs || _pollInterval;
// Initial load
fetch('./portals.json').then(r => r.json()).then(data => {
_lastHash = _hashPortals(data);
}).catch(() => {});
_interval = setInterval(_checkForChanges, _pollInterval);
console.log(`[PortalHotReload] Watching portals.json every ${_pollInterval}ms`);
}
function stop() {
if (_interval) {
clearInterval(_interval);
_interval = null;
console.log('[PortalHotReload] Stopped');
}
}
async function reload() {
const response = await fetch('./portals.json?t=' + Date.now());
const data = await response.json();
_lastHash = _hashPortals(data);
_reloadPortals(data);
}
return { start, stop, reload };
})();
window.PortalHotReload = PortalHotReload;

129
server.py
View File

@@ -15,7 +15,9 @@ import asyncio
import json
import logging
import os
import pty
import signal
import subprocess
import sys
import time
from typing import Set, Dict, Optional
@@ -25,9 +27,10 @@ from collections import defaultdict
import websockets
# Configuration
PORT = int(os.environ.get("NEXUS_WS_PORT", "8765"))
HOST = os.environ.get("NEXUS_WS_HOST", "127.0.0.1") # Default to localhost only
AUTH_TOKEN = os.environ.get("NEXUS_WS_TOKEN", "") # Empty = no auth required
PORT = int(os.environ.get("NEXUS_WS_PORT", "8765"))
PTY_PORT = int(os.environ.get("NEXUS_PTY_PORT", "8766")) # operator shell PTY gateway
HOST = os.environ.get("NEXUS_WS_HOST", "127.0.0.1") # Default to localhost only
AUTH_TOKEN = os.environ.get("NEXUS_WS_TOKEN", "") # Empty = no auth required
RATE_LIMIT_WINDOW = 60 # seconds
RATE_LIMIT_MAX_CONNECTIONS = 10 # max connections per IP per window
RATE_LIMIT_MAX_MESSAGES = 100 # max messages per connection per window
@@ -102,6 +105,116 @@ async def authenticate_connection(websocket: websockets.WebSocketServerProtocol)
logger.error(f"Authentication error from {websocket.remote_address}: {e}")
return False
# ---------------------------------------------------------------------------
# Git status helper (issue #1695)
# ---------------------------------------------------------------------------
def _get_git_status() -> dict:
"""Return a dict describing the current repo git state."""
repo_root = os.path.dirname(os.path.abspath(__file__))
try:
branch_out = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=repo_root, stderr=subprocess.DEVNULL, text=True
).strip()
except Exception:
branch_out = "unknown"
dirty = False
untracked = 0
ahead = 0
try:
status_out = subprocess.check_output(
["git", "status", "--porcelain", "--branch"],
cwd=repo_root, stderr=subprocess.DEVNULL, text=True
)
for line in status_out.splitlines():
if line.startswith("##") and "ahead" in line:
import re
m = re.search(r"ahead (\d+)", line)
if m:
ahead = int(m.group(1))
elif line.startswith("??"):
untracked += 1
elif line and not line.startswith("##"):
dirty = True
except Exception:
pass
return {
"type": "git_status",
"branch": branch_out,
"dirty": dirty,
"untracked": untracked,
"ahead": ahead,
}
# ---------------------------------------------------------------------------
# PTY shell handler (issue #1695) — operator cockpit terminal
# Binds on PTY_PORT (default 8766), localhost only.
# Each WebSocket connection gets its own PTY subprocess.
# ---------------------------------------------------------------------------
async def pty_handler(websocket: websockets.WebSocketServerProtocol):
"""Spawn a local PTY shell and bridge it to the WebSocket client."""
addr = websocket.remote_address
logger.info(f"[PTY] Operator shell connection from {addr}")
shell = os.environ.get("SHELL", "/bin/bash")
master_fd, slave_fd = pty.openpty()
proc = await asyncio.create_subprocess_exec(
shell,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
preexec_fn=os.setsid,
close_fds=True,
)
os.close(slave_fd)
loop = asyncio.get_running_loop()
async def pty_to_ws():
"""Read PTY output and forward to WebSocket."""
try:
while True:
data = await loop.run_in_executor(None, os.read, master_fd, 4096)
if not data:
break
await websocket.send(data.decode("utf-8", errors="replace"))
except (OSError, websockets.exceptions.ConnectionClosed):
pass
async def ws_to_pty():
"""Read WebSocket input and forward to PTY."""
try:
async for message in websocket:
if isinstance(message, str):
os.write(master_fd, message.encode("utf-8"))
else:
os.write(master_fd, message)
except (OSError, websockets.exceptions.ConnectionClosed):
pass
reader = asyncio.ensure_future(pty_to_ws())
writer = asyncio.ensure_future(ws_to_pty())
try:
await asyncio.gather(reader, writer)
finally:
reader.cancel()
writer.cancel()
try:
os.close(master_fd)
except OSError:
pass
try:
proc.kill()
except ProcessLookupError:
pass
await proc.wait()
logger.info(f"[PTY] Shell session ended for {addr}")
async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
"""Handles individual client connections and message broadcasting."""
addr = websocket.remote_address
@@ -140,6 +253,11 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
# Optional: log specific important message types
if msg_type in ["agent_register", "thought", "action"]:
logger.debug(f"Received {msg_type} from {addr}")
# Handle git status requests from the operator cockpit (issue #1695)
if msg_type == "git_status_request":
git_info = _get_git_status()
await websocket.send(json.dumps(git_info))
continue
except (json.JSONDecodeError, TypeError):
pass
@@ -210,7 +328,10 @@ async def main():
async with websockets.serve(broadcast_handler, HOST, PORT):
logger.info("Gateway is ready and listening.")
await stop
# Also start the PTY gateway on PTY_PORT (operator cockpit shell, issue #1695)
async with websockets.serve(pty_handler, "127.0.0.1", PTY_PORT):
logger.info(f"PTY shell gateway listening on ws://127.0.0.1:{PTY_PORT}/pty")
await stop
logger.info("Shutting down Nexus WS gateway...")
# Close any remaining client connections (handlers may have already cleaned up)

294
session-manager.js Normal file
View File

@@ -0,0 +1,294 @@
/**
* session-manager.js — Session taxonomy primitives for the Nexus operator cockpit
*
* Operations: create, list, setActive, group, tag, pin, archive, restore, delete
* Persistence: localStorage under key `nexus-sessions`
*
* Refs: issue #1695 — ATLAS cockpit operator patterns
* Pattern sources: dodo-reach/hermes-desktop session sidebar, nesquena/hermes-webui session groups
*/
(function () {
'use strict';
const STORAGE_KEY = 'nexus-sessions';
const META_KEY = 'nexus-sessions-meta';
// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------
// Session:
// id: string (uuid-ish)
// name: string
// group: string | null
// tags: string[]
// pinned: boolean
// archived: boolean
// createdAt: number (epoch ms)
// updatedAt: number
// meta: {} (arbitrary caller-supplied data)
//
// Meta:
// activeId: string | null
let _sessions = [];
let _activeId = null;
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
_sessions = raw ? JSON.parse(raw) : [];
} catch {
_sessions = [];
}
try {
const rawMeta = localStorage.getItem(META_KEY);
const meta = rawMeta ? JSON.parse(rawMeta) : {};
_activeId = meta.activeId || null;
} catch {
_activeId = null;
}
}
function save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(_sessions));
localStorage.setItem(META_KEY, JSON.stringify({ activeId: _activeId }));
} catch (e) {
console.warn('[SessionManager] Could not persist sessions:', e);
}
emit('change', { sessions: _sessions, activeId: _activeId });
}
// ---------------------------------------------------------------------------
// ID generation
// ---------------------------------------------------------------------------
function genId() {
return 'sess_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7);
}
// ---------------------------------------------------------------------------
// Event bus (lightweight)
// ---------------------------------------------------------------------------
const _listeners = {};
function on(event, fn) {
if (!_listeners[event]) _listeners[event] = [];
_listeners[event].push(fn);
}
function off(event, fn) {
if (!_listeners[event]) return;
_listeners[event] = _listeners[event].filter(f => f !== fn);
}
function emit(event, data) {
(_listeners[event] || []).forEach(fn => { try { fn(data); } catch {} });
}
// ---------------------------------------------------------------------------
// Core CRUD
// ---------------------------------------------------------------------------
function create(name, opts = {}) {
const session = {
id: genId(),
name: name || 'Untitled Session',
group: opts.group || null,
tags: opts.tags || [],
pinned: opts.pinned || false,
archived: false,
createdAt: Date.now(),
updatedAt: Date.now(),
meta: opts.meta || {},
};
_sessions.push(session);
if (!_activeId) _activeId = session.id;
save();
return session;
}
function get(id) {
return _sessions.find(s => s.id === id) || null;
}
function list(opts = {}) {
let result = [..._sessions];
if (opts.group) result = result.filter(s => s.group === opts.group);
if (opts.tag) result = result.filter(s => s.tags.includes(opts.tag));
if (opts.pinned !== undefined) result = result.filter(s => s.pinned === opts.pinned);
if (opts.archived !== undefined) result = result.filter(s => s.archived === opts.archived);
// Default: hide archived unless explicitly requested
if (opts.archived === undefined) result = result.filter(s => !s.archived);
// Pinned items first, then by updatedAt desc
result.sort((a, b) => {
if (a.pinned !== b.pinned) return b.pinned ? 1 : -1;
return b.updatedAt - a.updatedAt;
});
return result;
}
function update(id, patch) {
const idx = _sessions.findIndex(s => s.id === id);
if (idx < 0) return null;
_sessions[idx] = { ..._sessions[idx], ...patch, updatedAt: Date.now() };
save();
return _sessions[idx];
}
function remove(id) {
const before = _sessions.length;
_sessions = _sessions.filter(s => s.id !== id);
if (_activeId === id) _activeId = _sessions.find(s => !s.archived)?.id || null;
if (_sessions.length !== before) save();
}
// ---------------------------------------------------------------------------
// Taxonomy operations
// ---------------------------------------------------------------------------
/** Set or clear the group for a session. */
function group(id, groupName) {
return update(id, { group: groupName || null });
}
/** Add one or more tags to a session. */
function tag(id, ...tags) {
const s = get(id);
if (!s) return null;
const merged = Array.from(new Set([...s.tags, ...tags.filter(Boolean)]));
return update(id, { tags: merged });
}
/** Remove a tag from a session. */
function untag(id, tagName) {
const s = get(id);
if (!s) return null;
return update(id, { tags: s.tags.filter(t => t !== tagName) });
}
/** Toggle pin state. */
function pin(id) {
const s = get(id);
if (!s) return null;
return update(id, { pinned: !s.pinned });
}
/** Archive a session (hides from default list). */
function archive(id) {
return update(id, { archived: true, pinned: false });
}
/** Restore an archived session. */
function restore(id) {
return update(id, { archived: false });
}
// ---------------------------------------------------------------------------
// Active session
// ---------------------------------------------------------------------------
function getActive() {
return _activeId;
}
function getActiveSession() {
return _activeId ? get(_activeId) : null;
}
function setActive(id) {
if (!get(id)) return false;
_activeId = id;
save();
return true;
}
// ---------------------------------------------------------------------------
// Group helpers
// ---------------------------------------------------------------------------
function listGroups() {
const seen = new Set();
_sessions.forEach(s => { if (s.group) seen.add(s.group); });
return Array.from(seen).sort();
}
function listTags() {
const seen = new Set();
_sessions.forEach(s => s.tags.forEach(t => seen.add(t)));
return Array.from(seen).sort();
}
// ---------------------------------------------------------------------------
// Import / export (for backup / hand-off)
// ---------------------------------------------------------------------------
function exportAll() {
return JSON.stringify({ sessions: _sessions, activeId: _activeId }, null, 2);
}
function importAll(json) {
try {
const data = JSON.parse(json);
if (!Array.isArray(data.sessions)) throw new Error('Invalid format');
_sessions = data.sessions;
_activeId = data.activeId || null;
save();
return true;
} catch (e) {
console.error('[SessionManager] Import failed:', e);
return false;
}
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
load();
// Create a default session if store is empty
if (_sessions.length === 0) {
create('Default', { tags: ['auto'], meta: { auto: true } });
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
window.SessionManager = {
create,
get,
list,
update,
remove,
// Taxonomy
group,
tag,
untag,
pin,
archive,
restore,
// Active session
getActive,
getActiveSession,
setActive,
// Helpers
listGroups,
listTags,
// Import/export
exportAll,
importAll,
// Events
on,
off,
};
console.info('[SessionManager] Loaded. Sessions:', _sessions.length, '| Active:', _activeId);
})();

302
style.css
View File

@@ -2928,9 +2928,309 @@ body.operator-mode #mode-label {
.reasoning-trace {
width: 280px;
}
.trace-content {
max-height: 200px;
}
}
/* ==========================================================================
Operator Inspector Rail — issue #1695
========================================================================== */
.cockpit-inspector {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 280px;
background: rgba(5, 8, 20, 0.94);
border-left: 1px solid rgba(74, 240, 192, 0.18);
display: flex;
flex-direction: column;
z-index: 1200;
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 11px;
color: #c8e8ff;
backdrop-filter: blur(8px);
transition: transform 0.25s ease, width 0.25s ease;
overflow: hidden;
}
.cockpit-inspector.collapsed {
width: 32px;
}
.cockpit-inspector.collapsed .ci-header,
.cockpit-inspector.collapsed .ci-body {
display: none;
}
/* Toggle button */
.ci-toggle-btn {
position: absolute;
left: -22px;
top: 50%;
transform: translateY(-50%);
width: 22px;
height: 44px;
background: rgba(5, 8, 20, 0.9);
border: 1px solid rgba(74, 240, 192, 0.25);
border-right: none;
color: #4af0c0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
border-radius: 4px 0 0 4px;
z-index: 1;
}
.ci-toggle-btn:hover { background: rgba(74, 240, 192, 0.1); }
.ci-toggle-icon { line-height: 1; }
/* Header */
.ci-header {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 10px 8px;
border-bottom: 1px solid rgba(74, 240, 192, 0.12);
background: rgba(74, 240, 192, 0.04);
flex-shrink: 0;
}
.ci-header-icon { color: #4af0c0; font-size: 13px; }
.ci-header-title {
flex: 1;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
color: #4af0c0;
text-transform: uppercase;
}
.ci-header-actions { display: flex; gap: 4px; }
/* Generic icon button */
.ci-icon-btn {
background: none;
border: 1px solid rgba(74, 240, 192, 0.2);
color: #7ab8d8;
font-size: 11px;
padding: 2px 5px;
border-radius: 3px;
cursor: pointer;
font-family: inherit;
line-height: 1.4;
}
.ci-icon-btn:hover { border-color: #4af0c0; color: #4af0c0; }
/* Body scroll area */
.ci-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(74,240,192,0.2) transparent;
}
/* Section */
.ci-section {
border-bottom: 1px solid rgba(74, 240, 192, 0.08);
}
.ci-section-header {
display: flex;
align-items: center;
gap: 5px;
padding: 7px 10px 6px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.14em;
color: #7ab8d8;
text-transform: uppercase;
cursor: pointer;
user-select: none;
background: rgba(255,255,255,0.02);
}
.ci-section-header:hover { color: #4af0c0; }
.ci-section-icon { color: #4af0c0; font-size: 11px; width: 14px; text-align: center; }
.ci-section-actions { margin-left: auto; display: flex; gap: 4px; }
.ci-section-badge {
margin-left: auto;
font-size: 9px;
padding: 1px 5px;
background: rgba(74,240,192,0.08);
border-radius: 8px;
color: #4af0c0;
min-width: 18px;
text-align: center;
}
.ci-section-badge.badge-warn { background: rgba(255,160,40,0.15); color: #ffa028; }
.ci-section-badge.badge-ok { background: rgba(74,240,192,0.12); color: #4af0c0; }
.ci-section-body { padding: 6px 10px 8px; }
.ci-empty-hint {
color: rgba(200,232,255,0.35);
font-size: 10px;
font-style: italic;
padding: 2px 0;
}
/* Tag chip */
.ci-tag {
display: inline-block;
padding: 1px 5px;
background: rgba(74,240,192,0.08);
border: 1px solid rgba(74,240,192,0.18);
border-radius: 3px;
font-size: 9px;
color: #7ab8d8;
margin-right: 2px;
}
.tag-pin { border-color: rgba(255,180,40,0.4); background: rgba(255,180,40,0.08); }
.tag-archive { border-color: rgba(150,150,200,0.3); background: rgba(150,150,200,0.06); }
/* Git section rows */
.ci-git-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2px 0;
font-size: 10px;
}
.ci-git-label { color: rgba(200,232,255,0.5); }
.ci-git-value { color: #c8e8ff; font-weight: 500; }
.ci-dirty { color: #ffa028; }
.ci-clean { color: #4af0c0; }
/* Agent health rows */
.ci-agent-row {
display: flex;
align-items: flex-start;
gap: 7px;
padding: 5px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.ci-agent-row:last-child { border-bottom: none; }
.ci-agent-dot {
width: 7px; height: 7px;
border-radius: 50%;
margin-top: 4px;
flex-shrink: 0;
}
.agent-idle { background: #4af0c0; }
.agent-working { background: #ffa028; box-shadow: 0 0 4px #ffa028; animation: agent-pulse 1s infinite; }
.agent-error { background: #ff4060; }
.agent-unknown { background: rgba(200,232,255,0.3); }
@keyframes agent-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.ci-agent-info { flex: 1; min-width: 0; }
.ci-agent-id { font-size: 10px; color: #c8e8ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ci-agent-meta { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 2px; }
.ci-agent-task { font-size: 9px; color: rgba(200,232,255,0.5); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 140px; }
.ci-agent-status-label {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
border: 1px solid transparent;
white-space: nowrap;
flex-shrink: 0;
}
.ci-agent-status-label.agent-idle { border-color: rgba(74,240,192,0.3); color: #4af0c0; }
.ci-agent-status-label.agent-working { border-color: rgba(255,160,40,0.3); color: #ffa028; }
.ci-agent-status-label.agent-error { border-color: rgba(255,64,96,0.3); color: #ff4060; }
.ci-agent-status-label.agent-unknown { border-color: rgba(200,232,255,0.2); color: rgba(200,232,255,0.5); }
/* Session rows */
.ci-session-row {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 6px;
border-radius: 4px;
cursor: pointer;
margin-bottom: 2px;
}
.ci-session-row:hover { background: rgba(74,240,192,0.06); }
.ci-session-row.active { background: rgba(74,240,192,0.1); border-left: 2px solid #4af0c0; }
.ci-session-info { flex: 1; min-width: 0; }
.ci-session-name { font-size: 10px; color: #c8e8ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ci-session-meta { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 2px; }
.ci-session-actions { display: flex; gap: 2px; }
/* Artifact rows */
.ci-artifact-row {
display: flex;
align-items: center;
gap: 7px;
padding: 4px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.ci-artifact-row:last-child { border-bottom: none; }
.ci-artifact-icon { font-size: 13px; flex-shrink: 0; }
.ci-artifact-info { flex: 1; min-width: 0; }
.ci-artifact-name { font-size: 10px; color: #c8e8ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ci-artifact-path { font-size: 9px; color: rgba(200,232,255,0.4); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ci-artifact-type { flex-shrink: 0; }
/* Memory / skills rows */
.ci-mem-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 0;
}
.ci-mem-region { font-size: 10px; color: #c8e8ff; }
/* Terminal section */
.ci-terminal-section { flex: 0 0 auto; }
.ci-terminal-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 10px;
color: rgba(200,232,255,0.5);
}
.ci-term-dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.ci-term-dot.disconnected { background: rgba(200,232,255,0.25); }
.ci-term-dot.connecting { background: #ffa028; animation: agent-pulse 0.8s infinite; }
.ci-term-dot.connected { background: #4af0c0; }
.ci-terminal-mount {
margin: 0 10px 8px;
border: 1px solid rgba(74,240,192,0.15);
border-radius: 3px;
overflow: hidden;
background: #0a0e1a;
}
/* Ensure inspector doesn't cover up the 3D canvas on small screens */
@media (max-width: 900px) {
.cockpit-inspector { width: 220px; }
}
@media (max-width: 600px) {
.cockpit-inspector { display: none; }
}

View File

@@ -0,0 +1,125 @@
/**
* Tests for SpatialChatAudio component (issue #1544)
*/
import { SpatialChatAudio } from '../nexus/components/spatial-chat-audio.js';
// Mock DOM and THREE for Node.js testing
if (typeof document === 'undefined') {
global.document = {
addEventListener: () => {},
removeEventListener: () => {},
};
}
if (typeof THREE === 'undefined') {
global.THREE = {
Vector3: class {
constructor(x=0, y=0, z=0) { this.x = x; this.y = y; this.z = z; }
normalize() { return this; }
dot() { return 0; }
cross() { return this; }
},
MathUtils: { clamp: (v, min, max) => Math.max(min, Math.min(max, v)) },
AudioListener: class {
constructor() {
this.context = {
state: 'running',
currentTime: 0,
createOscillator: () => ({
type: 'sine',
frequency: { value: 440 },
connect: () => {},
start: () => {},
stop: () => {},
disconnect: () => {},
onended: null,
}),
createGain: () => ({
gain: { value: 1, setValueAtTime: () => {}, linearRampToValueAtTime: () => {}, exponentialRampToValueAtTime: () => {}, setTargetAtTime: () => {} },
connect: () => {},
disconnect: () => {},
}),
createStereoPanner: () => ({
pan: { value: 0, setValueAtTime: () => {}, setTargetAtTime: () => {} },
connect: () => {},
disconnect: () => {},
}),
createPanner: () => ({
panningModel: '',
distanceModel: '',
refDistance: 0,
maxDistance: 0,
rolloffFactor: 0,
positionX: { value: 0, setValueAtTime: () => {} },
positionY: { value: 0, setValueAtTime: () => {} },
positionZ: { value: 0, setValueAtTime: () => {} },
connect: () => {},
disconnect: () => {},
}),
resume: () => Promise.resolve(),
destination: {},
};
}
},
};
}
function assert(condition, message) {
if (!condition) {
console.error(`❌ FAILED: ${message}`);
process.exit(1);
}
console.log(`✔ PASSED: ${message}`);
}
console.log('--- Running SpatialChatAudio Tests ---');
// Test 1: Module exports
assert(typeof SpatialChatAudio === 'object', 'SpatialChatAudio exports an object');
assert(typeof SpatialChatAudio.init === 'function', 'SpatialChatAudio has init method');
assert(typeof SpatialChatAudio.playChatSound === 'function', 'SpatialChatAudio has playChatSound method');
assert(typeof SpatialChatAudio.createVoiceSource === 'function', 'SpatialChatAudio has createVoiceSource method');
// Test 2: Config defaults
assert(SpatialChatAudio.isEnabled() === true, 'Enabled by default');
assert(SpatialChatAudio.getMaxHearingDistance() === 40, 'Default max hearing distance is 40');
// Test 3: Configuration changes
SpatialChatAudio.setMaxHearingDistance(60);
assert(SpatialChatAudio.getMaxHearingDistance() === 60, 'Max hearing distance updated to 60');
SpatialChatAudio.setEnabled(false);
assert(SpatialChatAudio.isEnabled() === false, 'Can disable audio');
SpatialChatAudio.setEnabled(true);
assert(SpatialChatAudio.isEnabled() === true, 'Can re-enable audio');
// Test 4: Initialization with mock camera
const mockCamera = {
position: new THREE.Vector3(0, 0, 0),
getWorldDirection: () => new THREE.Vector3(1, 0, 0),
up: new THREE.Vector3(0, 1, 0),
add: () => {},
getObjectByProperty: () => null,
};
SpatialChatAudio.init(mockCamera);
assert(true, 'SpatialChatAudio initializes with camera');
// Test 5: Voice source creation
const voiceSource = SpatialChatAudio.createVoiceSource('timmy', new THREE.Vector3(10, 0, 0));
assert(voiceSource !== null, 'Voice source created');
assert(voiceSource.agentId === 'timmy', 'Voice source has correct agentId');
assert(typeof voiceSource.updatePosition === 'function', 'Voice source has updatePosition');
assert(typeof voiceSource.destroy === 'function', 'Voice source has destroy');
// Test 6: Voice source position update
voiceSource.updatePosition(new THREE.Vector3(20, 0, 0));
assert(true, 'Voice source position updated');
// Test 7: Voice source cleanup
voiceSource.destroy();
assert(true, 'Voice source destroyed');
console.log('--- All SpatialChatAudio Tests Passed ---');