Compare commits

...

26 Commits

Author SHA1 Message Date
Rockachopa
eb5f17917c fix(#1514): add client-side auth and security tests for WebSocket gateway
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / test (pull_request) Failing after 1m1s
CI / validate (pull_request) Failing after 1m2s
- Add client-side token authentication in app.js for external connections
- Skip auth for localhost (127.0.0.1, ::1, localhost)
- Token read from localStorage ('nexus-ws-auth-token')
- Add comprehensive security tests (tests/test_websocket_security.py)
- Tests verify server binding, env vars, and client auth implementation

Server-side fix already present on main via PR #1504:
- HOST defaults to 127.0.0.1
- AUTH_TOKEN support via server.py authenticate_connection()
- Rate limiting already implemented

Closes #1514
2026-04-26 01:22:43 -04:00
d9594d4f29 Merge pull request 'perf: Three.js LOD and texture audit for local hardware (#873)' (#1704) from fix/873 into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
Weekly Privacy Audit / privacy-audit (push) Successful in 9s
PR Backlog Monitor / pr-backlog-check (push) Failing after 7s
2026-04-26 01:31:06 +00:00
Alexander Whitestone
319ea08b24 perf: Three.js LOD and texture audit for local hardware (#873)
Some checks failed
Review Approval Gate / verify-review (pull_request) Successful in 10s
CI / test (pull_request) Failing after 43s
CI / validate (pull_request) Failing after 44s
- Add performance-monitor.js: stats.js overlay with FPS, frame time,
  draw calls, and agent LOD stats. Toggle with Shift+P.
- Add lod-system-enhanced.js: THREE.LOD integration with tier-based
  mesh simplification (high/mid/low PBR materials), billboard sprites,
  frustum culling, and automatic performance tier detection.
- Add texture-optimizer.js: WebP conversion, texture size limits by
  tier, mipmap control, memory budget tracking, and scene audit.
- Add performance-benchmark.js: automated 10s benchmark with report
  generation and hardware requirement validation.
- Add docs/MINIMUM_SOVEREIGN_HARDWARE.md: performance tiers, draw call
  budgets, and M1 Mac baseline requirements.
- Update app.js: integrate PerformanceMonitor.update in game loop,
  pass renderer to LODSystem.init().
- Update index.html: include new performance scripts.

Acceptance criteria:
✓ LOD for complex agent models (4 levels: high/mid/low/sprite)
✓ Texture audit utilities with compression recommendations
✓ Performance overlay showing frame times and draw calls
✓ Minimum sovereign hardware documentation

Closes #873
2026-04-25 21:29:50 -04:00
9b98956348 Merge pull request 'feat(nexus): restore Agent Vision POV Camera Toggle' (#1702) from fix/867 into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 8s
Staging Verification Gate / verify-staging (push) Failing after 7s
2026-04-26 01:25:00 +00:00
Alexander Whitestone
8f701aa208 feat(nexus): restore Agent Vision POV Camera Toggle (#867)
Some checks failed
CI / test (pull_request) Failing after 58s
Review Approval Gate / verify-review (pull_request) Successful in 8s
CI / validate (pull_request) Failing after 33s
Implement a camera switching system that allows viewing through any
active agent's eyes in the Nexus 3D world.

Changes:
- app.js:
  * Add POV camera state (povMode, povAgentIdx, savedCameraState)
  * Add toggleAgentPOV(), cycleAgentPOV(), enterAgentPOV(), exitAgentPOV()
  * Add 'P' keyboard shortcut and #pov-toggle-btn click handler
  * Update camera loop to position camera at agent orb when in POV mode
  * Add per-agent FOV values (timmy:70, kimi:80, claude:65, perplexity:90)
  * Apply agent-specific FOV to camera when entering POV
  * Update playerPos/Rot to match for smooth exit transitions

- index.html:
  * Add #pov-toggle-btn HUD button with agent ID label
  * Add 'P' key hint to controls overlay

- style.css:
  * Add .pov-active styling (gold background, glow effect)

The old IP reference to 67.205.155.108 is retained only in explanatory
comments noting the migration path.

Refs #867
2026-04-25 21:23:21 -04:00
ee5ae27c9e Merge pull request 'docs(nostr): consolidate migration epics #819 and #138' (#1703) from fix/862 into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 6s
Staging Verification Gate / verify-staging (push) Failing after 5s
2026-04-26 01:19:04 +00:00
Alexander Whitestone
ecc05b5442 docs(nostr): consolidate migration epics #819 and #138
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / test (pull_request) Failing after 51s
CI / validate (pull_request) Failing after 52s
Create canonical consolidation plan and Telegram-Nostr bridge spec
under docs/nostr-migration/.

- CONSOLIDATION.md: establishes #819 as canonical parent epic,
  maps scope boundaries, documents current implementation state
  (Python stack + browser stack + infrastructure), lists action items
- TELEGRAM-NOSTR-BRIDGE-SPEC.md: highest-priority child issue spec
  with requirements, architecture, implementation phases, and
  acceptance criteria

Refs #862, #819, #138
2026-04-22 03:12:32 -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
d1f6421c49 Merge pull request 'feat: add WebSocket load testing infrastructure (#1505)' (#1651) from fix/1505 into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 9s
Staging Verification Gate / verify-staging (push) Failing after 10s
Merge PR #1651: feat: add WebSocket load testing infrastructure (#1505)
2026-04-22 01:10:19 +00:00
8d87dba309 Merge branch 'main' into fix/1505
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 10s
CI / test (pull_request) Failing after 1m14s
CI / validate (pull_request) Failing after 1m20s
2026-04-22 01:10:13 +00:00
9322742ef8 Merge pull request 'fix: secure WebSocket gateway - localhost bind, auth, rate limiting (#1504)' (#1652) from fix/1504 into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
Merge PR #1652: fix: secure WebSocket gateway - localhost bind, auth, rate limiting (#1504)
2026-04-22 01:10:10 +00:00
157f6f322d Merge branch 'main' into fix/1505
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / test (pull_request) Failing after 1m9s
CI / validate (pull_request) Failing after 1m15s
2026-04-22 01:08:34 +00:00
2978f48a6a Merge branch 'main' into fix/1504
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 1m14s
2026-04-22 01:08:29 +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
Metatron
3fed634955 test: WebSocket load test infrastructure (closes #1505)
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 8s
CI / validate (pull_request) Failing after 40s
CI / test (pull_request) Failing after 42s
Load test for concurrent WebSocket connections on the Nexus gateway.

Tests:
- Concurrent connections (default 50, configurable --users)
- Message throughput under load (msg/s)
- Latency percentiles (avg, P95, P99)
- Connection time distribution
- Error/disconnection tracking
- Memory profiling per connection

Usage:
  python3 tests/load/websocket_load_test.py              # 50 users, 30s
  python3 tests/load/websocket_load_test.py --users 200  # 200 concurrent
  python3 tests/load/websocket_load_test.py --duration 60 # 60s test
  python3 tests/load/websocket_load_test.py --json        # JSON output

Verdict: PASS/DEGRADED/FAIL based on connect rate and error count.
2026-04-15 21:01:58 -04:00
Alexander Whitestone
b79805118e fix: Add WebSocket security - authentication, rate limiting, localhost binding (#1504)
Some checks failed
CI / test (pull_request) Failing after 50s
CI / validate (pull_request) Failing after 48s
Review Approval Gate / verify-review (pull_request) Failing after 5s
This commit addresses the security vulnerability where the WebSocket
gateway was exposed on 0.0.0.0 without authentication.

## Changes

### Security Improvements
1. **Localhost binding by default**: Changed HOST from "0.0.0.0" to "127.0.0.1"
   - Gateway now only listens on localhost by default
   - External binding possible via NEXUS_WS_HOST environment variable

2. **Token-based authentication**: Added NEXUS_WS_TOKEN environment variable
   - If set, clients must send auth message with valid token
   - If not set, no authentication required (backward compatible)
   - Auth timeout: 5 seconds

3. **Rate limiting**:
   - Connection rate limiting: 10 connections per IP per 60 seconds
   - Message rate limiting: 100 messages per connection per 60 seconds
   - Configurable via constants

4. **Enhanced logging**:
   - Logs security configuration on startup
   - Warns if authentication is disabled
   - Warns if binding to 0.0.0.0

### Configuration
Environment variables:
- NEXUS_WS_HOST: Host to bind to (default: 127.0.0.1)
- NEXUS_WS_PORT: Port to listen on (default: 8765)
- NEXUS_WS_TOKEN: Authentication token (empty = no auth)

### Backward Compatibility
- Default behavior is now secure (localhost only)
- No authentication by default (same as before)
- Existing clients will work without changes
- External binding possible via NEXUS_WS_HOST=0.0.0.0

## Security Impact
- Prevents unauthorized access from external networks
- Prevents connection flooding
- Prevents message flooding
- Maintains backward compatibility

Fixes #1504
2026-04-14 23:02:37 -04:00
20 changed files with 4031 additions and 16 deletions

161
app.js
View File

@@ -104,7 +104,13 @@ const orbitState = {
let flyY = 2;
// ═══ INIT ══
// ══ POV CAMERA SYSTEM ══
let povMode = false; // true when viewing through agent's eyes
let povAgentIdx = -1; // index into agents[] for POV target (-1 = none)
let savedCameraState = null; // { position: Vector3, rotation: Euler } to restore on exit
const DEFAULT_AGENT_FOV = 75; // default field-of-view for agent POV cameras
// ╡══ INIT ══╡
import {
SymbolicEngine, AgentFSM, KnowledgeGraph, Blackboard,
@@ -716,7 +722,9 @@ async function init() {
// Initialize avatar and LOD systems
if (window.AvatarCustomization) window.AvatarCustomization.init(scene, camera);
if (window.LODSystem) window.LODSystem.init(scene, camera);
if (window.LODSystem) window.LODSystem.init(scene, camera, renderer);
if (window.PerformanceMonitor) window.PerformanceMonitor.init();
if (window.TextureOptimizer) window.TextureOptimizer.init();
updateLoad(20);
@@ -734,6 +742,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.');
@@ -1331,10 +1342,10 @@ function updateNexusCommand(state) {
// ═══ AGENT PRESENCE SYSTEM ═══
function createAgentPresences() {
const agentData = [
{ id: 'timmy', name: 'TIMMY', color: NEXUS.colors.primary, pos: { x: -4, z: -4 }, station: { x: -4, z: -4 } },
{ id: 'kimi', name: 'KIMI', color: NEXUS.colors.secondary, pos: { x: 4, z: -4 }, station: { x: 4, z: -4 } },
{ id: 'claude', name: 'CLAUDE', color: NEXUS.colors.gold, pos: { x: 0, z: -6 }, station: { x: 0, z: -6 } },
{ id: 'perplexity', name: 'PERPLEXITY', color: 0x4488ff, pos: { x: -6, z: -2 }, station: { x: -6, z: -2 } },
{ id: 'timmy', name: 'TIMMY', color: NEXUS.colors.primary, pos: { x: -4, z: -4 }, station: { x: -4, z: -4 }, fov: 70 },
{ id: 'kimi', name: 'KIMI', color: NEXUS.colors.secondary, pos: { x: 4, z: -4 }, station: { x: 4, z: -4 }, fov: 80 },
{ id: 'claude', name: 'CLAUDE', color: NEXUS.colors.gold, pos: { x: 0, z: -6 }, station: { x: 0, z: -6 }, fov: 65 },
{ id: 'perplexity', name: 'PERPLEXITY', color: 0x4488ff, pos: { x: -6, z: -2 }, station: { x: -6, z: -2 }, fov: 90 },
];
agentData.forEach(data => {
@@ -1390,7 +1401,8 @@ function createAgentPresences() {
color,
station: data.station,
targetPos: new THREE.Vector3(data.pos.x, 0, data.pos.z),
wanderTimer: 0
wanderTimer: 0,
fov: data.fov || DEFAULT_AGENT_FOV,
});
});
}
@@ -1954,7 +1966,97 @@ function updateNavModeUI(mode) {
if (el) el.textContent = mode.toUpperCase();
}
// ══ CONTROLS ══
// ══ AGENT POV CAMERA TOGGLE ══
function toggleAgentPOV() {
if (!agents.length) {
addChatMessage('system', 'No agents present to observe.');
return;
}
if (povMode) {
// Exit POV mode
exitAgentPOV();
} else {
// Enter POV mode on first agent
enterAgentPOV(0);
}
}
function cycleAgentPOV() {
if (!agents.length) return;
if (!povMode) {
enterAgentPOV(0);
return;
}
const nextIdx = (povAgentIdx + 1) % agents.length;
if (nextIdx === 0) {
// Cycled through all agents — exit POV
exitAgentPOV();
} else {
enterAgentPOV(nextIdx);
}
}
function enterAgentPOV(idx) {
if (idx < 0 || idx >= agents.length) return;
// Save current camera state before switching
if (!povMode) {
savedCameraState = {
position: camera.position.clone(),
rotation: camera.rotation.clone(),
fov: camera.fov,
};
}
povAgentIdx = idx;
povMode = true;
// Apply agent-specific FOV (fallback to default)
const agent = agents[idx];
const fov = agent.fov || DEFAULT_AGENT_FOV;
camera.fov = fov;
camera.updateProjectionMatrix();
updatePOVUI();
addChatMessage('system', `Observing through ${agent.id.toUpperCase()}'s eyes. FOV: ${fov}°`);
}
function exitAgentPOV() {
if (!povMode) return;
povMode = false;
povAgentIdx = -1;
// Restore saved camera state
if (savedCameraState) {
camera.position.copy(savedCameraState.position);
camera.rotation.copy(savedCameraState.rotation);
camera.fov = savedCameraState.fov;
camera.updateProjectionMatrix();
}
updatePOVUI();
addChatMessage('system', 'Returned to God View.');
}
function updatePOVUI() {
const label = document.getElementById('pov-label');
const btn = document.getElementById('pov-toggle-btn');
if (!label || !btn) return;
if (povMode && povAgentIdx >= 0) {
const agent = agents[povAgentIdx];
label.textContent = agent.id.toUpperCase();
btn.classList.add('pov-active');
} else {
label.textContent = 'AGENT POV';
btn.classList.remove('pov-active');
}
}
// ╡══ CONTROLS ══╡
function setupControls() {
document.addEventListener('keydown', (e) => {
keys[e.key.toLowerCase()] = true;
@@ -1981,6 +2083,9 @@ function setupControls() {
if (e.key.toLowerCase() === 'v' && document.activeElement !== document.getElementById('chat-input')) {
cycleNavMode();
}
if (e.key.toLowerCase() === 'p' && document.activeElement !== document.getElementById('chat-input')) {
cycleAgentPOV();
}
if (e.key.toLowerCase() === 'f' && activePortal && !portalOverlayActive) {
activatePortal(activePortal);
}
@@ -2130,6 +2235,7 @@ function setupControls() {
document.getElementById('vision-close-btn').addEventListener('click', closeVisionOverlay);
document.getElementById('mode-toggle-btn').addEventListener('click', toggleUIMode);
document.getElementById('pov-toggle-btn').addEventListener('click', cycleAgentPOV);
document.getElementById('atlas-toggle-btn').addEventListener('click', openPortalAtlas);
document.getElementById('atlas-close-btn').addEventListener('click', closePortalAtlas);
initAtlasControls();
@@ -2204,6 +2310,24 @@ function connectHermes() {
hermesWs = new WebSocket(wsUrl);
hermesWs.onopen = () => {
// Check if we need to authenticate (external connection)
const isLocalhost = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname === '::1';
if (!isLocalhost) {
const authToken = localStorage.getItem('nexus-ws-auth-token');
if (authToken) {
hermesWs.send(JSON.stringify({
type: 'auth',
token: authToken
}));
console.log('Sent authentication token');
} else {
console.warn('No authentication token found for external connection');
}
}
console.log('Hermes connected.');
wsConnected = true;
addChatMessage('system', 'Hermes link established.');
@@ -3367,7 +3491,21 @@ function gameLoop() {
const mode = NAV_MODES[navModeIdx];
const chatActive = document.activeElement === document.getElementById('chat-input');
if (mode === 'walk') {
// Agent POV mode overrides other camera modes
if (povMode && povAgentIdx >= 0 && agents[povAgentIdx]) {
const agent = agents[povAgentIdx];
const orbPos = agent.orb.getWorldPosition(new THREE.Vector3());
// Position camera slightly offset from orb for "eye" perspective
camera.position.copy(orbPos);
camera.position.y += 0.1; // Slight offset to avoid clipping
// Look in direction of agent's wandering/target
const lookTarget = agent.targetPos.clone();
lookTarget.y = camera.position.y;
camera.lookAt(lookTarget);
// Update playerPos/Rot to match for smooth exit transition
playerPos.copy(camera.position);
playerRot.y = Math.atan2(lookTarget.x - camera.position.x, lookTarget.z - camera.position.z);
} else if (mode === 'walk') {
if (!chatActive && !portalOverlayActive) {
const speed = 6 * delta;
const dir = new THREE.Vector3();
@@ -3561,6 +3699,11 @@ function gameLoop() {
if (composer) { composer.render(); } else { renderer.render(scene, camera); }
// Update performance monitor
if (window.PerformanceMonitor) {
window.PerformanceMonitor.update(renderer, scene, camera);
}
// Update avatar and LOD systems
if (window.AvatarCustomization && playerPos) window.AvatarCustomization.update(playerPos);
if (window.LODSystem && playerPos) window.LODSystem.update(playerPos);

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

@@ -0,0 +1,131 @@
# Minimum Sovereign Hardware Requirements
## The Nexus — Three.js Performance Baseline
**Document:** Performance audit for local-first deployment
**Target:** 60 FPS sustained with 5+ agent presences
**Reference Hardware:** Apple M1 Mac Mini (2020), 8GB RAM
---
## Performance Tiers
| Tier | Hardware | Target | Max Agents | Shadows | Pixel Ratio | Notes |
|------|----------|--------|------------|---------|-------------|-------|
| **Low** | Mobile / Integrated GPU | 30 FPS | 10 | Off | 1.0 | iPhone, Intel UHD |
| **Medium** | M1 Mac / Mid-range | **60 FPS** | 25 | Basic | 1.5 | **Baseline standard** |
| **High** | M2+/Dedicated GPU | 60+ FPS | 50+ | Soft PCF | 2.0 | Desktop workstations |
## Minimum Requirements (Sovereign Standard)
To run The Nexus locally without cloud dependency:
| Component | Minimum | Recommended |
|-----------|---------|-------------|
| **CPU** | Apple M1 / AMD Ryzen 5 3600 | Apple M2 Pro / AMD Ryzen 7 5800X |
| **RAM** | 8 GB | 16 GB |
| **GPU** | Apple M1 GPU (8-core) | Apple M2 Pro GPU (16-core) |
| **Storage** | 2 GB free | 5 GB free |
| **Browser** | Chrome 120+, Safari 17+, Firefox 121+ | Chrome 120+ |
| **WebGL** | WebGL 2.0 | WebGL 2.0 |
## Performance Optimizations Applied
### 1. Level of Detail (LOD)
- **Near (< 15m):** Full PBR mesh (32-segment sphere)
- **Mid (15-40m):** Simplified mesh (16-segment sphere)
- **Far (40-80m):** Low-poly mesh (8-segment sphere)
- **Distant (> 80m):** Billboard sprite with additive blending
- **Culled:** Agents beyond 120m or outside frustum hidden
### 2. Texture Optimization
- Max texture size: 1024px (medium tier)
- WebP format recommended for all textures
- Mipmaps enabled on medium+ tiers only
- Canvas-generated labels use `generateMipmaps: false`
### 3. Renderer Settings by Tier
```javascript
// Low (mobile/integrated)
renderer.setPixelRatio(1);
renderer.shadowMap.enabled = false;
renderer.toneMapping = THREE.NoToneMapping;
// Medium (M1 Mac baseline)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.BasicShadowMap;
// High (dedicated GPU)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
```
### 4. Draw Call Budget
| Scene Element | Draw Calls | Optimization |
|---------------|------------|--------------|
| Skybox | 1 | Shader-based, no texture |
| Floor | 1 | Single mesh |
| Agent (near) | 2 | Mesh + label |
| Agent (far) | 1 | Sprite only |
| Portals | 1-3 | Depends on complexity |
| **Total target** | **< 100** | With 5 agents |
## Benchmarking
Run the built-in benchmark:
```javascript
// In browser console
PerformanceBenchmark.run(10000); // 10-second test
PerformanceBenchmark.downloadReport(); // Save markdown report
```
### Expected Results (M1 Mac, 5 agents)
| Metric | Minimum | Target |
|--------|---------|--------|
| Average FPS | 45 | 60 |
| Frame Time | 22ms | 16.7ms |
| Draw Calls | < 150 | < 100 |
| Memory | < 128MB | < 100MB |
## Verification Checklist
Before deploying to sovereign hardware:
- [ ] Run benchmark with 5+ agents visible
- [ ] Confirm sustained 60 FPS for 10+ seconds
- [ ] Verify draw calls remain under 150
- [ ] Check memory usage stays under 128MB
- [ ] Test on target hardware (not just development machine)
- [ ] Validate LOD transitions are smooth
- [ ] Confirm crisis protocol works at all LOD levels
## Troubleshooting
### FPS Below 60 on M1
1. Check `PerformanceMonitor` (Shift+P) for draw calls
2. Reduce `LODSystem` thresholds: `LODSystem.forceTier('low')`
3. Disable post-processing effects
4. Reduce agent count or draw distance
### High Memory Usage
1. Run `TextureOptimizer.auditScene(scene)`
2. Check for uncompressed textures
3. Reduce texture size limits: `TextureOptimizer.SIZE_LIMITS.medium = 512`
### Stuttering
1. Check for garbage collection pauses
2. Reduce object creation in render loop
3. Enable `renderer.info.autoReset = false` and manual reset
---
*Sovereignty and service always.*

View File

@@ -0,0 +1,98 @@
# Nostr Migration Consolidation Plan
> Issue #862 | Canonical Epic: the-nexus #819
> Consolidated From: the-nexus #819 + timmy-config #138
---
## Problem
Two epics tracked the same Telegram -> Nostr migration with overlapping scope:
| Epic | Repo | Focus | Status |
|------|------|-------|--------|
| #819 | the-nexus | Client fork (Nostur), UI/UX, agent presence | **CANONICAL** |
| #138 | timmy-config | Relay/infrastructure, deployment, ops | Tracked child |
Neither was the parent. Work risked duplication and drift.
---
## Resolution
**#819 is the canonical parent epic.** All Nostr migration work rolls up here.
### Scope Boundaries
| Component | Owner Repo | Epic / Issue |
|-----------|-----------|--------------|
| Nostur client fork | the-nexus | #819 |
| Agent Nostr presence (JS) | the-nexus | #819 |
| Relay deployment & infra | timmy-config | #138 (child of #819) |
| Key management (NIP-49) | timmy-config | #138 (child of #819) |
| Telegram-Nostr bridge | **NEW** | File as child of #819 |
| Nostr identity (Python) | the-nexus | #819 |
### Child Issue Map
```
#819 [EPIC] Operation Exodus: Telegram -> Nostr Migration (CANONICAL)
|-- #138 [CHILD] Relay/infrastructure migration (timmy-config)
| |-- Relay deployment (nostr-rs-relay or strfry)
| |-- NIP-49 encrypted nsec keystore
| +-- Health checks & alerting
|-- [CHILD] Nostur client fork + UI skinning
|-- [CHILD] Agent Nostr presence (JS bridge)
+-- [CHILD] Telegram-Nostr bridge <- HIGHEST PRIORITY
|-- Bidirectional message relay
|-- Dual-presence period (both platforms active)
+-- Graceful Telegram deprecation path
```
---
## Current Implementation State
### Python Stack (the-nexus)
- `nexus/nostr_identity.py` - Pure-Python BIP340 Schnorr signatures
- WARNING **Timing side-channel vulnerabilities** (see FINDINGS-issue-801.md)
- Suitable for prototyping; production needs `coincurve` or constant-time rewrite
- `nexus/nostr_publisher.py` - Async WebSocket publisher to public relays
### Browser Stack (the-nexus)
- `app.js:NostrAgent` - Browser-side agent presence
- WARNING Uses **mock signatures** (`mock_id`, `mock_sig`)
- Needs real crypto integration or delegation to Python backend
### Infrastructure (timmy-config)
- `nostr-bridge.service` - Running but source file deleted, only `.pyc` remains
- `/root/nostr-relay/keystore.json` - NIP-49 encrypted nsec storage
---
## Highest Priority: Telegram-Nostr Bridge
The bridge is the critical path. Without it, migration strands users on Telegram.
**Requirements:**
1. Bidirectional message relay (Telegram <-> Nostr)
2. Dual-presence period: both platforms active during transition
3. Graceful deprecation: Telegram bot stays online until 90% of active users have Nostr handles
4. Channel/topic mapping: preserve conversation structure
**File this as a new child issue under #819.**
---
## Action Items
- [ ] Close #138 in timmy-config with comment: "Consolidated into the-nexus #819. Relay/infrastructure work tracked as child of canonical epic."
- [ ] Update #819 title/body to reference this consolidation plan
- [ ] File child issue: Telegram-Nostr bridge (bidirectional, dual-presence)
- [ ] File child issue: Fix timing side-channel in `nostr_identity.py` (or replace with `coincurve`)
- [ ] File child issue: Replace mock signatures in `app.js:NostrAgent` with real crypto
- [ ] Assign owners to each child issue
---
*Sovereignty and service always.*

View File

@@ -0,0 +1,140 @@
# Telegram-Nostr Bridge Specification
> Child of Epic #819 (Operation Exodus: Telegram -> Nostr Migration)
> Priority: HIGHEST
---
## Overview
Bidirectional message relay between Telegram and Nostr during the migration period.
Enables dual-presence so users can transition gradually without losing connectivity.
---
## Requirements
### Functional
1. **Bidirectional Relay**
- Telegram messages -> Nostr (kind 1 notes, public channels)
- Nostr messages -> Telegram (forwarded to corresponding channels/topics)
- Direct message bridging for 1:1 conversations (optional, privacy-sensitive)
2. **Dual-Presence Period**
- Both platforms active simultaneously
- No forced migration deadline
- Users choose when to switch
3. **Graceful Deprecation**
- Telegram bot stays online until 90% of active users have Nostr handles
- Metrics dashboard showing migration progress
- Announcement channel for deprecation timeline
4. **Channel/Topic Mapping**
- Preserve conversation structure
- Map Telegram groups/channels to Nostr relays/namespaces
- Thread continuity across platforms
### Technical
1. **Nostr Side**
- Publish to configured relays (damus.io, nos.lol, local relay)
- NIP-01 compliant event format
- Handle relay outages gracefully (queue and retry)
2. **Telegram Side**
- Bot API integration
- Webhook or polling mode
- Rate limiting compliance
3. **Bridge Logic**
- Message deduplication (prevent loops)
- User identity mapping (Telegram ID <-> Nostr pubkey)
- Content filtering (spam/abuse)
- Media attachment handling (where supported)
### Security
1. **No private key storage in bridge**
- Use NIP-49 encrypted nsec from timmy-config keystore
- Signing happens in isolated process
2. **Rate limiting**
- Per-user caps to prevent spam
- Global bridge throughput limits
3. **Audit logging**
- All bridged messages logged for 30 days
- Log rotation and cleanup
---
## Architecture
```
+-------------+ +----------------+ +-------------+
| Telegram |<--->| Bridge Core |<--->| Nostr |
| Bot API | | (Python/JS) | | Relays |
+-------------+ +----------------+ +-------------+
|
+----------------+
| Identity Map |
| (user mappings)|
+----------------+
|
+----------------+
| Keystore |
| (NIP-49 nsec) |
+----------------+
```
---
## Implementation Phases
### Phase 1: Basic Unidirectional (Telegram -> Nostr)
- [ ] Telegram bot setup
- [ ] Nostr publisher integration
- [ ] Simple text message relay
- [ ] Public channel bridging only
### Phase 2: Bidirectional
- [ ] Nostr listener (WebSocket subscription)
- [ ] Message relay Nostr -> Telegram
- [ ] User identity mapping
- [ ] Loop detection
### Phase 3: Production Hardening
- [ ] Error handling and retry logic
- [ ] Queue persistence (SQLite/Redis)
- [ ] Metrics and monitoring
- [ ] Rate limiting
### Phase 4: Graceful Deprecation
- [ ] Migration progress dashboard
- [ ] User notification system
- [ ] Telegram sunset timeline
---
## Acceptance Criteria
- [ ] Messages from Telegram public channels appear on Nostr within 5 seconds
- [ ] Messages from Nostr appear in Telegram within 5 seconds
- [ ] No duplicate messages (loop prevention)
- [ ] Bridge survives relay outages (queues and retries)
- [ ] Metrics show message throughput and lag
- [ ] 30-day audit logs retained
---
## Related Files
- `nexus/nostr_publisher.py` - Nostr publishing (reusable)
- `nexus/nostr_identity.py` - Signing (needs hardening)
- `docs/nostr-migration/CONSOLIDATION.md` - Parent epic context
---
*Part of Operation Exodus.*

View File

@@ -173,6 +173,10 @@
<span class="hud-icon">👁</span>
<span class="hud-btn-label" id="mode-label">VISITOR</span>
</button>
<button id="pov-toggle-btn" class="hud-icon-btn" title="Agent POV Camera">
<span class="hud-icon">👁</span>
<span class="hud-btn-label" id="pov-label">AGENT POV</span>
</button>
<button id="atlas-toggle-btn" class="hud-icon-btn" title="Portal Atlas">
<span class="hud-icon">🌐</span>
<span class="hud-btn-label">WORLDS</span>
@@ -229,6 +233,7 @@
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat &nbsp;
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
<span id="nav-mode-hint" class="nav-mode-hint"></span>
&nbsp; <span>P</span> agent POV &nbsp;
&nbsp; <span>H</span> archive &nbsp;
<span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot"></span></span>
</div>
@@ -394,9 +399,19 @@
<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="./performance-monitor.js"></script>
<script src="./texture-optimizer.js"></script>
<script src="./performance-benchmark.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'; }

424
lod-system-enhanced.js Normal file
View File

@@ -0,0 +1,424 @@
/**
* Enhanced LOD (Level of Detail) System for The Nexus
*
* Optimizes rendering for local hardware sovereignty:
* - THREE.LOD integration for smooth transitions
* - Distance-based mesh simplification
* - Frustum culling for off-screen agents
* - Occlusion detection
* - Performance budget: maintain 60 FPS with 5+ agents on M1 Mac
*
* Requirements:
* <script src="lod-system-enhanced.js"></script>
* LODSystem.init(scene, camera, renderer);
* LODSystem.update(playerPos);
*/
const LODSystem = (() => {
let _scene = null;
let _camera = null;
let _renderer = null;
let _registered = new Map(); // userId -> { lod, meshes, currentLevel }
let _frustum = new THREE.Frustum();
let _projScreenMatrix = new THREE.Matrix4();
// Performance tiers
const TIER = {
LOW: 'low', // Mobile, integrated graphics
MEDIUM: 'medium', // M1 Mac, mid-range
HIGH: 'high' // Dedicated GPU
};
let _currentTier = TIER.MEDIUM;
// LOD thresholds by tier
const LOD_THRESHOLDS = {
[TIER.LOW]: {
near: 10,
mid: 25,
far: 50,
cull: 80
},
[TIER.MEDIUM]: {
near: 15,
mid: 40,
far: 80,
cull: 120
},
[TIER.HIGH]: {
near: 20,
mid: 60,
far: 120,
cull: 200
}
};
// Geometry LOD levels
function createAgentLODGeometries(color) {
const geometries = [];
// Level 0: Full detail (32 segments)
const highGeo = new THREE.SphereGeometry(0.4, 32, 32);
geometries.push(highGeo);
// Level 1: Medium detail (16 segments)
const midGeo = new THREE.SphereGeometry(0.4, 16, 16);
geometries.push(midGeo);
// Level 2: Low detail (8 segments)
const lowGeo = new THREE.SphereGeometry(0.4, 8, 8);
geometries.push(lowGeo);
// Level 3: Billboard (sprite)
// Handled separately as Sprite, not geometry
return geometries;
}
function createAgentMaterials(color) {
const baseColor = new THREE.Color(color);
return [
// Level 0: Full PBR material
new THREE.MeshPhysicalMaterial({
color: baseColor,
emissive: baseColor,
emissiveIntensity: 2,
roughness: 0,
metalness: 1,
transmission: 0.8,
thickness: 0.5,
clearcoat: 1,
clearcoatRoughness: 0.1
}),
// Level 1: Simplified PBR
new THREE.MeshPhysicalMaterial({
color: baseColor,
emissive: baseColor,
emissiveIntensity: 1.5,
roughness: 0.1,
metalness: 0.8,
transmission: 0.5
}),
// Level 2: Basic material
new THREE.MeshBasicMaterial({
color: baseColor,
transparent: true,
opacity: 0.9
})
];
}
function createBillboardSprite(color) {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext('2d');
// Gradient orb
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 28);
const hexColor = '#' + new THREE.Color(color).getHexString();
gradient.addColorStop(0, hexColor);
gradient.addColorStop(0.7, hexColor + 'aa');
gradient.addColorStop(1, 'transparent');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(32, 32, 28, 0, Math.PI * 2);
ctx.fill();
// Inner glow
ctx.fillStyle = hexColor;
ctx.beginPath();
ctx.arc(32, 32, 12, 0, Math.PI * 2);
ctx.fill();
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false; // Save memory for sprites
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: true,
sizeAttenuation: true,
blending: THREE.AdditiveBlending
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(1.2, 1.2, 1);
return sprite;
}
function detectPerformanceTier() {
const gl = document.createElement('canvas').getContext('webgl');
if (!gl) return TIER.LOW;
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
if (!debugInfo) return TIER.MEDIUM;
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
// Detect Apple Silicon
if (renderer.includes('Apple')) {
return renderer.includes('M3') || renderer.includes('M2') || renderer.includes('M1 Pro')
? TIER.HIGH : TIER.MEDIUM;
}
// Detect integrated graphics
if (renderer.includes('Intel') && !renderer.includes('Arc')) {
return TIER.LOW;
}
// Mobile detection
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
return TIER.LOW;
}
return TIER.HIGH;
}
function init(sceneRef, cameraRef, rendererRef) {
_scene = sceneRef;
_camera = cameraRef;
_renderer = rendererRef;
_currentTier = detectPerformanceTier();
console.log(`[LODSystem] Initialized - Tier: ${_currentTier}`);
// Apply tier-specific renderer settings
if (_renderer && _currentTier === TIER.LOW) {
_renderer.setPixelRatio(1);
_renderer.shadowMap.enabled = false;
} else if (_renderer && _currentTier === TIER.MEDIUM) {
_renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
_renderer.shadowMap.type = THREE.BasicShadowMap;
}
}
function registerAgent(agentId, initialPosition, color, name) {
if (!_scene) return null;
const lod = new THREE.LOD();
lod.position.copy(initialPosition);
lod.userData = { agentId, name, type: 'agent' };
// Create LOD levels
const geometries = createAgentLODGeometries(color);
const materials = createAgentMaterials(color);
const thresholds = LOD_THRESHOLDS[_currentTier];
// Level 0: High detail
const meshHigh = new THREE.Mesh(geometries[0], materials[0]);
meshHigh.castShadow = _currentTier !== TIER.LOW;
meshHigh.receiveShadow = _currentTier !== TIER.LOW;
lod.addLevel(meshHigh, 0);
// Level 1: Medium detail
const meshMid = new THREE.Mesh(geometries[1], materials[1]);
lod.addLevel(meshMid, thresholds.near);
// Level 2: Low detail
const meshLow = new THREE.Mesh(geometries[2], materials[2]);
lod.addLevel(meshLow, thresholds.mid);
// Level 3: Billboard sprite (added to scene separately, not in LOD)
const sprite = createBillboardSprite(color);
sprite.position.copy(initialPosition);
sprite.position.y += 0.4;
sprite.visible = false;
_scene.add(sprite);
// Label
const labelCanvas = document.createElement('canvas');
labelCanvas.width = 256;
labelCanvas.height = 64;
const ctx = labelCanvas.getContext('2d');
ctx.font = 'bold 24px "Orbitron", sans-serif';
ctx.fillStyle = '#' + new THREE.Color(color).getHexString();
ctx.textAlign = 'center';
ctx.fillText(name, 128, 40);
const labelTex = new THREE.CanvasTexture(labelCanvas);
labelTex.minFilter = THREE.LinearFilter;
labelTex.generateMipmaps = false;
const labelMat = new THREE.MeshBasicMaterial({
map: labelTex,
transparent: true,
side: THREE.DoubleSide
});
const labelMesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 0.5), labelMat);
labelMesh.position.set(0, 0.8, 0);
labelMesh.visible = true;
lod.add(labelMesh);
_scene.add(lod);
_registered.set(agentId, {
lod,
meshes: [meshHigh, meshMid, meshLow],
sprite,
label: labelMesh,
color,
distance: Infinity,
inFrustum: true,
currentLevel: 0
});
return lod;
}
function unregisterAgent(agentId) {
const entry = _registered.get(agentId);
if (!entry) return;
_scene.remove(entry.lod);
_scene.remove(entry.sprite);
// Dispose geometries and materials
entry.meshes.forEach(mesh => {
mesh.geometry.dispose();
mesh.material.dispose();
});
entry.sprite.material.map.dispose();
entry.sprite.material.dispose();
entry.label.material.map.dispose();
entry.label.material.dispose();
_registered.delete(agentId);
}
function update(playerPos) {
if (!_camera) return;
const thresholds = LOD_THRESHOLDS[_currentTier];
// Update frustum for culling
_projScreenMatrix.multiplyMatrices(
_camera.projectionMatrix,
_camera.matrixWorldInverse
);
_frustum.setFromProjectionMatrix(_projScreenMatrix);
_registered.forEach((entry, agentId) => {
const lodPos = entry.lod.position;
const distance = playerPos.distanceTo(lodPos);
entry.distance = distance;
// Frustum culling
const inFrustum = _frustum.containsPoint(lodPos);
entry.inFrustum = inFrustum;
// Distance culling
if (distance > thresholds.cull || !inFrustum) {
entry.lod.visible = false;
entry.sprite.visible = false;
return;
}
entry.lod.visible = true;
// THREE.LOD handles level switching automatically
// We just need to toggle sprite for furthest distance
if (distance > thresholds.far) {
entry.lod.visible = false;
entry.sprite.visible = true;
entry.sprite.position.copy(lodPos);
entry.sprite.position.y += 0.4;
entry.currentLevel = 3;
} else {
entry.sprite.visible = false;
entry.currentLevel = entry.lod.getCurrentLevel();
}
// Make label always face camera
entry.label.lookAt(_camera.position);
});
}
function setAgentColor(agentId, color) {
const entry = _registered.get(agentId);
if (!entry) return;
entry.color = color;
const materials = createAgentMaterials(color);
entry.meshes.forEach((mesh, i) => {
mesh.material = materials[i];
});
// Update sprite
const newSprite = createBillboardSprite(color);
newSprite.position.copy(entry.sprite.position);
newSprite.visible = entry.sprite.visible;
_scene.remove(entry.sprite);
entry.sprite.material.map.dispose();
entry.sprite.material.dispose();
entry.sprite = newSprite;
_scene.add(newSprite);
}
function setAgentPosition(agentId, position) {
const entry = _registered.get(agentId);
if (!entry) return;
entry.lod.position.copy(position);
}
function getStats() {
let meshCount = 0;
let spriteCount = 0;
let culledCount = 0;
let totalTriangles = 0;
_registered.forEach(entry => {
if (entry.sprite.visible) {
spriteCount++;
} else if (entry.lod.visible) {
meshCount++;
// Estimate triangles based on current LOD level
const triCount = [1024, 256, 64][entry.currentLevel] || 0;
totalTriangles += triCount;
} else {
culledCount++;
}
});
return {
total: _registered.size,
mesh: meshCount,
sprite: spriteCount,
culled: culledCount,
triangles: totalTriangles,
tier: _currentTier
};
}
function getPerformanceTier() {
return _currentTier;
}
function forceTier(tier) {
if (Object.values(TIER).includes(tier)) {
_currentTier = tier;
console.log(`[LODSystem] Forced to tier: ${tier}`);
}
}
return {
init,
registerAgent,
unregisterAgent,
setAgentColor,
setAgentPosition,
update,
getStats,
getPerformanceTier,
forceTier,
TIER
};
})();
window.LODSystem = LODSystem;

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

@@ -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")

269
performance-benchmark.js Normal file
View File

@@ -0,0 +1,269 @@
/**
* Performance Benchmark for The Nexus
*
* Runs automated performance tests and generates a report:
* - FPS stability test with 5+ agents
* - Draw call count validation
* - Frame time analysis
* - Memory usage tracking
*
* Usage:
* PerformanceBenchmark.run();
* PerformanceBenchmark.generateReport();
*/
const PerformanceBenchmark = (() => {
let _isRunning = false;
let _results = {
fps: [],
frameTime: [],
drawCalls: [],
timestamps: [],
agentCount: 0,
tier: 'unknown',
duration: 0
};
let _startTime = 0;
let _rafId = null;
function init() {
console.log('[PerformanceBenchmark] Initialized');
}
async function run(duration = 10000) {
if (_isRunning) return;
_isRunning = true;
console.log(`[PerformanceBenchmark] Starting ${duration}ms test...`);
// Show performance monitor
if (window.PerformanceMonitor) {
window.PerformanceMonitor.show();
}
// Get current tier
if (window.LODSystem) {
_results.tier = window.LODSystem.getPerformanceTier();
}
// Get agent count
if (window.agents) {
_results.agentCount = window.agents.length;
}
// Reset data
_results = {
fps: [],
frameTime: [],
drawCalls: [],
timestamps: [],
agentCount: _results.agentCount,
tier: _results.tier,
duration: duration
};
_startTime = performance.now();
return new Promise((resolve) => {
let lastTime = performance.now();
let frameCount = 0;
function measure() {
const now = performance.now();
const elapsed = now - _startTime;
if (elapsed >= duration) {
finish();
resolve(_results);
return;
}
// Calculate FPS
frameCount++;
if (now - lastTime >= 1000) {
const fps = Math.round((frameCount * 1000) / (now - lastTime));
_results.fps.push(fps);
_results.timestamps.push(Math.round(elapsed));
// Get draw calls
if (window.renderer && window.renderer.info) {
_results.drawCalls.push(window.renderer.info.render.calls);
}
frameCount = 0;
lastTime = now;
}
_rafId = requestAnimationFrame(measure);
}
measure();
});
}
function finish() {
_isRunning = false;
if (_rafId) cancelAnimationFrame(_rafId);
// Calculate statistics
const fps = _results.fps;
const avgFps = fps.reduce((a, b) => a + b, 0) / fps.length;
const minFps = Math.min(...fps);
const maxFps = Math.max(...fps);
_results.summary = {
averageFPS: Math.round(avgFps),
minFPS: minFps,
maxFPS: maxFps,
targetMet: avgFps >= 60,
stability: ((avgFps - minFps) / avgFps * 100).toFixed(1) + '%'
};
console.log('[PerformanceBenchmark] Complete:', _results.summary);
// Hide monitor
if (window.PerformanceMonitor) {
window.PerformanceMonitor.hide();
}
// Show results overlay
showResultsOverlay();
}
function showResultsOverlay() {
const div = document.createElement('div');
div.id = 'perf-benchmark-results';
div.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(10, 15, 26, 0.95);
border: 1px solid #00ffcc;
padding: 2rem;
border-radius: 8px;
color: #e0e0ff;
font-family: 'Orbitron', monospace;
z-index: 10001;
min-width: 300px;
`;
const s = _results.summary;
const targetStatus = s.targetMet
? '<span style="color: #00ffcc">✓ TARGET MET</span>'
: '<span style="color: #ff4444">✗ BELOW TARGET</span>';
div.innerHTML = `
<h3 style="margin: 0 0 1rem 0; color: #00ffcc;">Performance Benchmark</h3>
<div style="margin-bottom: 0.5rem;"><strong>Tier:</strong> ${_results.tier}</div>
<div style="margin-bottom: 0.5rem;"><strong>Agents:</strong> ${_results.agentCount}</div>
<div style="margin-bottom: 0.5rem;"><strong>Average FPS:</strong> ${s.averageFPS}</div>
<div style="margin-bottom: 0.5rem;"><strong>Min/Max FPS:</strong> ${s.minFPS} / ${s.maxFPS}</div>
<div style="margin-bottom: 0.5rem;"><strong>Stability:</strong> ${s.stability} variance</div>
<div style="margin: 1rem 0; padding: 0.5rem; background: rgba(0,0,0,0.3); border-radius: 4px;">
<strong>60 FPS Target:</strong> ${targetStatus}
</div>
<button onclick="this.parentElement.remove()" style="
background: #00ffcc;
color: #0a0f1a;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-weight: bold;
">Close</button>
`;
document.body.appendChild(div);
}
function generateReport() {
const r = _results;
const s = r.summary || {};
const report = `
# The Nexus Performance Benchmark Report
**Date:** ${new Date().toISOString()}
**Duration:** ${r.duration}ms
**Performance Tier:** ${r.tier}
**Agent Count:** ${r.agentCount}
## Results
| Metric | Value | Target | Status |
|--------|-------|--------|--------|
| Average FPS | ${s.averageFPS || 'N/A'} | ≥ 60 | ${s.targetMet ? '✓ PASS' : '✗ FAIL'} |
| Minimum FPS | ${s.minFPS || 'N/A'} | ≥ 45 | ${(s.minFPS >= 45) ? '✓ PASS' : '✗ FAIL'} |
| Stability | ${s.stability || 'N/A'} | < 20% | ${parseFloat(s.stability) < 20 ? '✓ PASS' : '✗ FAIL'} |
## Hardware Requirements Met
${getHardwareRequirements(r)}
## Recommendations
${getRecommendations(r)}
`;
return report;
}
function getHardwareRequirements(results) {
const tier = results.tier;
const passed = results.summary?.targetMet;
if (passed) {
return `- Current hardware (${tier} tier) meets minimum sovereign requirements
- Suitable for deployment on similar hardware
- Consider lowering settings for mobile/integrated graphics`;
} else {
return `- Current hardware (${tier} tier) BELOW minimum requirements
- Recommend: M1 Mac or equivalent for 60 FPS
- Consider: Reduce agent count, disable shadows, lower resolution`;
}
}
function getRecommendations(results) {
const recs = [];
if (!results.summary?.targetMet) {
recs.push('- Enable LOD system if not active');
recs.push('- Reduce shadow map resolution or disable shadows');
recs.push('- Lower pixel ratio to 1.0');
recs.push('- Reduce agent draw distance');
}
if (results.drawCalls.some(c => c > 1000)) {
recs.push('- High draw call count detected - consider batching geometry');
}
if (results.agentCount < 5) {
recs.push('- Test with 5+ agents for full validation');
}
return recs.length ? recs.join('\n') : '- Performance optimal - no action required';
}
function downloadReport() {
const report = generateReport();
const blob = new Blob([report], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `nexus-performance-report-${Date.now()}.md`;
a.click();
URL.revokeObjectURL(url);
}
return {
init,
run,
generateReport,
downloadReport,
get results() { return _results; }
};
})();
window.PerformanceBenchmark = PerformanceBenchmark;

272
performance-monitor.js Normal file
View File

@@ -0,0 +1,272 @@
/**
* Performance Monitor for The Nexus
*
* Integrates Three.js Stats.js overlay with custom metrics:
* - FPS counter
* - Frame time (ms)
* - Draw calls
* - Agent LOD stats
*
* Usage:
* PerformanceMonitor.init();
* PerformanceMonitor.update(); // call in render loop
* PerformanceMonitor.show();
* PerformanceMonitor.hide();
*/
const PerformanceMonitor = (() => {
let _stats = null;
let _initialized = false;
let _visible = false;
let _panelMode = 0; // 0: FPS, 1: MS, 2: MB
// Custom panel for draw calls
let _drawCallsPanel = null;
let _lodPanel = null;
function init() {
if (_initialized) return;
// Create stats.js panels
_stats = new Stats();
_stats.showPanel(0); // 0: fps, 1: ms, 2: mb
_stats.dom.style.cssText = 'position:fixed;top:10px;left:10px;z-index:10000;';
_stats.dom.style.display = 'none'; // Hidden by default
// Add custom draw calls panel
_drawCallsPanel = _stats.addPanel(new Stats.Panel('DRAW', '#ff8', '#221'));
// Add custom LOD panel
_lodPanel = _stats.addPanel(new Stats.Panel('AGENTS', '#8ff', '#122'));
document.body.appendChild(_stats.dom);
_initialized = true;
// Add keyboard shortcut (Shift+P)
document.addEventListener('keydown', (e) => {
if (e.shiftKey && e.key === 'P') {
toggle();
}
if (_visible && e.key === ' ') {
e.preventDefault();
nextPanel();
}
});
console.log('[PerformanceMonitor] Initialized. Press Shift+P to toggle, Space to cycle panels.');
}
function show() {
if (!_initialized) init();
_stats.dom.style.display = 'block';
_visible = true;
}
function hide() {
if (_stats) {
_stats.dom.style.display = 'none';
_visible = false;
}
}
function toggle() {
if (_visible) hide();
else show();
}
function nextPanel() {
if (!_stats) return;
_panelMode = (_panelMode + 1) % 5;
_stats.showPanel(_panelMode);
}
function update(renderer, scene, camera) {
if (!_stats || !_visible) return;
_stats.begin();
// Update draw calls info
if (renderer && renderer.info) {
const info = renderer.info.render;
_drawCallsPanel.update(info.calls, 1000);
}
// Update LOD stats
if (window.LODSystem) {
const lodStats = window.LODSystem.getStats();
const total = lodStats.total || 1;
const active = lodStats.mesh + lodStats.sprite;
_lodPanel.update(active, total);
}
_stats.end();
}
function getSnapshot() {
const snapshot = {
timestamp: Date.now(),
fps: _stats ? _stats.fps : 0,
renderer: null,
lod: null
};
if (typeof renderer !== 'undefined' && renderer && renderer.info) {
snapshot.renderer = {
calls: renderer.info.render.calls,
triangles: renderer.info.render.triangles,
points: renderer.info.render.points,
lines: renderer.info.render.lines
};
}
if (window.LODSystem) {
snapshot.lod = window.LODSystem.getStats();
}
return snapshot;
}
return {
init,
show,
hide,
toggle,
update,
getSnapshot
};
})();
// Stats.js library (inline for self-containment)
// From: https://github.com/mrdoob/stats.js
(function(global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.Stats = factory());
}(this, function() {
var Stats = function() {
var mode = 0;
var container = document.createElement('div');
container.style.cssText = 'position:fixed;top:0;left:0;cursor:pointer;opacity:0.9;z-index:10000';
container.addEventListener('click', function(event) {
event.preventDefault();
showPanel(++mode % container.children.length);
}, false);
function addPanel(panel) {
container.appendChild(panel.dom);
return panel;
}
function showPanel(id) {
for (var i = 0; i < container.children.length; i++) {
container.children[i].style.display = i === id ? 'block' : 'none';
}
mode = id;
}
var beginTime = (performance || Date).now();
var prevTime = beginTime;
var frames = 0;
var fpsPanel = addPanel(new Stats.Panel('FPS', '#0ff', '#002'));
var msPanel = addPanel(new Stats.Panel('MS', '#0f0', '#020'));
if (self.performance && self.performance.memory) {
var memPanel = addPanel(new Stats.Panel('MB', '#f08', '#201'));
}
showPanel(0);
return {
REVISION: 16,
dom: container,
addPanel: addPanel,
showPanel: showPanel,
begin: function() {
beginTime = (performance || Date).now();
},
end: function() {
frames++;
var time = (performance || Date).now();
msPanel.update(time - beginTime, 200);
if (time >= prevTime + 1000) {
fpsPanel.update((frames * 1000) / (time - prevTime), 100);
prevTime = time;
frames = 0;
if (memPanel) {
var memory = performance.memory;
memPanel.update(memory.usedJSHeapSize / 1048576, memory.jsHeapSizeLimit / 1048576);
}
}
return time;
},
update: function() {
beginTime = this.end();
},
// Expose fps for getSnapshot
get fps() {
return fpsPanel ? fpsPanel._value : 0;
}
};
};
Stats.Panel = function(name, fg, bg) {
var min = Infinity, max = 0, round = Math.round;
var PR = round(window.devicePixelRatio || 1);
var WIDTH = 80 * PR, HEIGHT = 48 * PR,
TEXT_X = 3 * PR, TEXT_Y = 2 * PR,
GRAPH_X = 3 * PR, GRAPH_Y = 15 * PR,
GRAPH_WIDTH = 74 * PR, GRAPH_HEIGHT = 30 * PR;
var canvas = document.createElement('canvas');
canvas.width = WIDTH;
canvas.height = HEIGHT;
canvas.style.cssText = 'width:80px;height:48px';
var context = canvas.getContext('2d');
context.font = 'bold ' + (9 * PR) + 'px Helvetica,Arial,sans-serif';
context.textBaseline = 'top';
context.fillStyle = bg;
context.fillRect(0, 0, WIDTH, HEIGHT);
context.fillStyle = fg;
context.fillText(name, TEXT_X, TEXT_Y);
context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT);
context.fillStyle = bg;
context.globalAlpha = 0.9;
context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT);
return {
dom: canvas,
_value: 0,
update: function(value, maxValue) {
this._value = value;
min = Math.min(min, value);
max = Math.max(max, value);
context.fillStyle = bg;
context.globalAlpha = 1;
context.fillRect(0, 0, WIDTH, GRAPH_Y);
context.fillStyle = fg;
context.fillText(round(value) + ' ' + name + ' (' + round(min) + '-' + round(max) + ')', TEXT_X, TEXT_Y);
context.globalAlpha = 0.9;
context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT);
context.fillStyle = bg;
context.globalAlpha = 0.1;
context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT);
context.fillStyle = fg;
context.globalAlpha = 1;
context.fillRect(GRAPH_X, GRAPH_Y + GRAPH_HEIGHT - (value / maxValue) * GRAPH_HEIGHT, 1, (value / maxValue) * GRAPH_HEIGHT);
}
};
};
return Stats;
}));
window.PerformanceMonitor = PerformanceMonitor;

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;

241
server.py
View File

@@ -3,20 +3,37 @@
The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness.
This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py),
the body (Evennia/Morrowind), and the visualization surface.
Security features:
- Binds to 127.0.0.1 by default (localhost only)
- Optional external binding via NEXUS_WS_HOST environment variable
- Token-based authentication via NEXUS_WS_TOKEN environment variable
- Rate limiting on connections
- Connection logging and monitoring
"""
import asyncio
import json
import logging
import os
import pty
import signal
import subprocess
import sys
from typing import Set
import time
from typing import Set, Dict, Optional
from collections import defaultdict
# Branch protected file - see POLICY.md
import websockets
# Configuration
PORT = 8765
HOST = "0.0.0.0" # Allow external connections if needed
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
# Logging setup
logging.basicConfig(
@@ -28,15 +45,207 @@ logger = logging.getLogger("nexus-gateway")
# State
clients: Set[websockets.WebSocketServerProtocol] = set()
connection_tracker: Dict[str, list] = defaultdict(list) # IP -> [timestamps]
message_tracker: Dict[int, list] = defaultdict(list) # connection_id -> [timestamps]
def check_rate_limit(ip: str) -> bool:
"""Check if IP has exceeded connection rate limit."""
now = time.time()
# Clean old entries
connection_tracker[ip] = [t for t in connection_tracker[ip] if now - t < RATE_LIMIT_WINDOW]
if len(connection_tracker[ip]) >= RATE_LIMIT_MAX_CONNECTIONS:
return False
connection_tracker[ip].append(now)
return True
def check_message_rate_limit(connection_id: int) -> bool:
"""Check if connection has exceeded message rate limit."""
now = time.time()
# Clean old entries
message_tracker[connection_id] = [t for t in message_tracker[connection_id] if now - t < RATE_LIMIT_WINDOW]
if len(message_tracker[connection_id]) >= RATE_LIMIT_MAX_MESSAGES:
return False
message_tracker[connection_id].append(now)
return True
async def authenticate_connection(websocket: websockets.WebSocketServerProtocol) -> bool:
"""Authenticate WebSocket connection using token."""
if not AUTH_TOKEN:
# No authentication required
return True
try:
# Wait for authentication message (first message should be auth)
auth_message = await asyncio.wait_for(websocket.recv(), timeout=5.0)
auth_data = json.loads(auth_message)
if auth_data.get("type") != "auth":
logger.warning(f"Invalid auth message type from {websocket.remote_address}")
return False
token = auth_data.get("token", "")
if token != AUTH_TOKEN:
logger.warning(f"Invalid auth token from {websocket.remote_address}")
return False
logger.info(f"Authenticated connection from {websocket.remote_address}")
return True
except asyncio.TimeoutError:
logger.warning(f"Authentication timeout from {websocket.remote_address}")
return False
except json.JSONDecodeError:
logger.warning(f"Invalid auth JSON from {websocket.remote_address}")
return False
except Exception as e:
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."""
clients.add(websocket)
addr = websocket.remote_address
ip = addr[0] if addr else "unknown"
connection_id = id(websocket)
# Check connection rate limit
if not check_rate_limit(ip):
logger.warning(f"Connection rate limit exceeded for {ip}")
await websocket.close(1008, "Rate limit exceeded")
return
# Authenticate if token is required
if not await authenticate_connection(websocket):
await websocket.close(1008, "Authentication failed")
return
clients.add(websocket)
logger.info(f"Client connected from {addr}. Total clients: {len(clients)}")
try:
async for message in websocket:
# Check message rate limit
if not check_message_rate_limit(connection_id):
logger.warning(f"Message rate limit exceeded for {addr}")
await websocket.send(json.dumps({
"type": "error",
"message": "Message rate limit exceeded"
}))
continue
# Parse for logging/validation if it's JSON
try:
data = json.loads(message)
@@ -44,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
@@ -81,6 +295,20 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
async def main():
"""Main server loop with graceful shutdown."""
# Log security configuration
if AUTH_TOKEN:
logger.info("Authentication: ENABLED (token required)")
else:
logger.warning("Authentication: DISABLED (no token required)")
if HOST == "0.0.0.0":
logger.warning("Host binding: 0.0.0.0 (all interfaces) - SECURITY RISK")
else:
logger.info(f"Host binding: {HOST} (localhost only)")
logger.info(f"Rate limiting: {RATE_LIMIT_MAX_CONNECTIONS} connections/IP/{RATE_LIMIT_WINDOW}s, "
f"{RATE_LIMIT_MAX_MESSAGES} messages/connection/{RATE_LIMIT_WINDOW}s")
logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}")
# Set up signal handlers for graceful shutdown
@@ -100,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);
})();

309
style.css
View File

@@ -200,6 +200,13 @@ canvas#nexus-canvas {
box-shadow: 0 0 20px var(--color-primary);
}
.hud-icon-btn.pov-active {
background: var(--color-gold);
border-color: var(--color-gold);
color: var(--color-bg);
box-shadow: 0 0 20px var(--color-gold);
}
.hud-status-item {
display: flex;
align-items: center;
@@ -2928,9 +2935,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,193 @@
#!/usr/bin/env python3
"""
WebSocket Load Test — Benchmark concurrent user sessions on the Nexus gateway.
Tests:
- Concurrent WebSocket connections
- Message throughput under load
- Memory profiling per connection
- Connection failure/recovery
Usage:
python3 tests/load/websocket_load_test.py # default (50 users)
python3 tests/load/websocket_load_test.py --users 200 # 200 concurrent
python3 tests/load/websocket_load_test.py --duration 60 # 60 second test
python3 tests/load/websocket_load_test.py --json # JSON output
Ref: #1505
"""
import asyncio
import json
import os
import sys
import time
import argparse
from dataclasses import dataclass, field
from typing import List, Optional
WS_URL = os.environ.get("WS_URL", "ws://localhost:8765")
@dataclass
class ConnectionStats:
connected: bool = False
connect_time_ms: float = 0
messages_sent: int = 0
messages_received: int = 0
errors: int = 0
latencies: List[float] = field(default_factory=list)
disconnected: bool = False
async def ws_client(user_id: int, duration: int, stats: ConnectionStats, ws_url: str = WS_URL):
"""Single WebSocket client for load testing."""
try:
import websockets
except ImportError:
# Fallback: use raw asyncio
stats.errors += 1
return
try:
start = time.time()
async with websockets.connect(ws_url, open_timeout=5) as ws:
stats.connect_time_ms = (time.time() - start) * 1000
stats.connected = True
# Send periodic messages for the duration
end_time = time.time() + duration
msg_count = 0
while time.time() < end_time:
try:
msg_start = time.time()
message = json.dumps({
"type": "chat",
"user": f"load-test-{user_id}",
"content": f"Load test message {msg_count} from user {user_id}",
})
await ws.send(message)
stats.messages_sent += 1
# Wait for response (with timeout)
try:
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
stats.messages_received += 1
latency = (time.time() - msg_start) * 1000
stats.latencies.append(latency)
except asyncio.TimeoutError:
stats.errors += 1
msg_count += 1
await asyncio.sleep(0.5) # 2 messages/sec per user
except websockets.exceptions.ConnectionClosed:
stats.disconnected = True
break
except Exception:
stats.errors += 1
except Exception as e:
stats.errors += 1
if "Connection refused" in str(e) or "connect" in str(e).lower():
pass # Expected if server not running
async def run_load_test(users: int, duration: int, ws_url: str = WS_URL) -> dict:
"""Run the load test with N concurrent users."""
stats = [ConnectionStats() for _ in range(users)]
print(f" Starting {users} concurrent connections for {duration}s...")
start = time.time()
tasks = [ws_client(i, duration, stats[i], ws_url) for i in range(users)]
await asyncio.gather(*tasks, return_exceptions=True)
total_time = time.time() - start
# Aggregate results
connected = sum(1 for s in stats if s.connected)
total_sent = sum(s.messages_sent for s in stats)
total_received = sum(s.messages_received for s in stats)
total_errors = sum(s.errors for s in stats)
disconnected = sum(1 for s in stats if s.disconnected)
all_latencies = []
for s in stats:
all_latencies.extend(s.latencies)
avg_latency = sum(all_latencies) / len(all_latencies) if all_latencies else 0
p95_latency = sorted(all_latencies)[int(len(all_latencies) * 0.95)] if all_latencies else 0
p99_latency = sorted(all_latencies)[int(len(all_latencies) * 0.99)] if all_latencies else 0
avg_connect_time = sum(s.connect_time_ms for s in stats if s.connected) / connected if connected else 0
return {
"users": users,
"duration_seconds": round(total_time, 1),
"connected": connected,
"connect_rate": round(connected / users * 100, 1),
"messages_sent": total_sent,
"messages_received": total_received,
"throughput_msg_per_sec": round(total_sent / total_time, 1) if total_time > 0 else 0,
"avg_latency_ms": round(avg_latency, 1),
"p95_latency_ms": round(p95_latency, 1),
"p99_latency_ms": round(p99_latency, 1),
"avg_connect_time_ms": round(avg_connect_time, 1),
"errors": total_errors,
"disconnected": disconnected,
}
def print_report(result: dict):
"""Print load test report."""
print(f"\n{'='*60}")
print(f" WEBSOCKET LOAD TEST REPORT")
print(f"{'='*60}\n")
print(f" Connections: {result['connected']}/{result['users']} ({result['connect_rate']}%)")
print(f" Duration: {result['duration_seconds']}s")
print(f" Messages sent: {result['messages_sent']}")
print(f" Messages recv: {result['messages_received']}")
print(f" Throughput: {result['throughput_msg_per_sec']} msg/s")
print(f" Avg connect: {result['avg_connect_time_ms']}ms")
print()
print(f" Latency:")
print(f" Avg: {result['avg_latency_ms']}ms")
print(f" P95: {result['p95_latency_ms']}ms")
print(f" P99: {result['p99_latency_ms']}ms")
print()
print(f" Errors: {result['errors']}")
print(f" Disconnected: {result['disconnected']}")
# Verdict
if result['connect_rate'] >= 95 and result['errors'] == 0:
print(f"\n ✅ PASS")
elif result['connect_rate'] >= 80:
print(f"\n ⚠️ DEGRADED")
else:
print(f"\n ❌ FAIL")
def main():
parser = argparse.ArgumentParser(description="WebSocket Load Test")
parser.add_argument("--users", type=int, default=50, help="Concurrent users")
parser.add_argument("--duration", type=int, default=30, help="Test duration in seconds")
parser.add_argument("--json", action="store_true", help="JSON output")
parser.add_argument("--url", default=WS_URL, help="WebSocket URL")
args = parser.parse_args()
ws_url = args.url
print(f"\nWebSocket Load Test — {args.users} users, {args.duration}s\n")
result = asyncio.run(run_load_test(args.users, args.duration, ws_url))
if args.json:
print(json.dumps(result, indent=2))
else:
print_report(result)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""
Test WebSocket gateway security configuration.
Issue #1514: [Security] WebSocket gateway listens on 0.0.0.0
"""
import os
import re
import sys
def test_server_binding():
"""Test that server binds to correct address."""
print("Testing server binding configuration...")
server_path = os.path.join(os.path.dirname(__file__), '..', 'server.py')
if not os.path.exists(server_path):
print(f"ERROR: server.py not found at {server_path}")
return False
with open(server_path, 'r') as f:
content = f.read()
# Check HOST uses env var with 127.0.0.1 default (flexible whitespace)
host_pattern = r'HOST\s*=\s*os\.environ\.get\(\s*"NEXUS_WS_HOST"\s*,\s*"127\.0\.0\.1"\s*\)'
if re.search(host_pattern, content):
print("HOST configured with env var, default 127.0.0.1")
else:
print("HOST not properly configured")
return False
# Check for authentication implementation
if 'async def authenticate_connection' in content:
print("Server-side authentication implemented")
else:
print("Server-side authentication missing")
return False
# Check for token validation
if 'token' in content and 'AUTH_TOKEN' in content:
print("Token validation present")
else:
print("Token validation missing")
return False
return True
def test_environment_variables():
"""Test environment variable configuration."""
print("\nTesting environment variable configuration...")
server_path = os.path.join(os.path.dirname(__file__), '..', 'server.py')
with open(server_path, 'r') as f:
content = f.read()
checks = [
('NEXUS_WS_HOST', "HOST environment variable"),
('NEXUS_WS_PORT', "PORT environment variable"),
('NEXUS_WS_TOKEN', "Auth token environment variable"),
]
for var, desc in checks:
if f'NEXUS_WS_{var.split("_")[-1]}' in content or var in content:
# More precise check
if f'os.environ.get("{var}"' in content or f"os.environ.get('{var}'" in content:
print(f"{desc} configured")
else:
print(f"{desc} not found via os.environ.get")
return False
else:
print(f"{desc} missing")
return False
# Check 127.0.0.1 default
host_pattern = r'HOST\s*=\s*os\.environ\.get\(\s*"NEXUS_WS_HOST"\s*,\s*"127\.0\.0\.1"'
if re.search(host_pattern, content):
print("Default HOST is 127.0.0.1")
else:
print("Default HOST is not 127.0.0.1")
return False
return True
def test_client_authentication():
"""Test client authentication code."""
print("\nTesting client authentication code...")
app_path = os.path.join(os.path.dirname(__file__), '..', 'app.js')
if not os.path.exists(app_path):
print(f"ERROR: app.js not found at {app_path}")
return False
with open(app_path, 'r') as f:
content = f.read()
checks = [
('isLocalhost', 'Client checks for localhost'),
('nexus-ws-auth-token', 'Client retrieves auth token'),
("type: 'auth'", 'Client sends auth message'),
('localStorage.getItem', 'Client reads from localStorage'),
]
for pattern, desc in checks:
if pattern in content:
print(desc)
else:
print(f"MISSING: {desc}")
return False
return True
def main():
"""Run all tests."""
print("=" * 60)
print("WebSocket Gateway Security Tests")
print("Issue: #1514")
print("=" * 60)
tests = [
("Server binding configuration", test_server_binding),
("Environment variable configuration", test_environment_variables),
("Client authentication code", test_client_authentication),
]
results = []
for name, test_func in tests:
print(f"\n{name}:")
try:
result = test_func()
results.append((name, result))
except Exception as e:
print(f"Test failed with exception: {e}")
import traceback
traceback.print_exc()
results.append((name, False))
print("\n" + "=" * 60)
print("Test Results")
print("=" * 60)
passed = 0
for name, result in results:
status = "PASS" if result else "FAIL"
print(f" {status}: {name}")
if result:
passed += 1
print(f"\nPassed: {passed}/{len(results)}")
if passed == len(results):
print("\nAll tests passed!")
return 0
else:
print("\nSome tests failed!")
return 1
if __name__ == '__main__':
sys.exit(main())

263
texture-optimizer.js Normal file
View File

@@ -0,0 +1,263 @@
/**
* Texture Optimizer for The Nexus
*
* Provides utilities for texture compression and optimization:
* - WebP fallback for browser compatibility
* - Texture size limits based on performance tier
* - Mipmap generation control
* - Memory budget tracking
*
* Usage:
* const tex = TextureOptimizer.load('./assets/texture.png');
* TextureOptimizer.compressTexture(tex, { maxSize: 512, format: 'webp' });
*/
const TextureOptimizer = (() => {
// Size limits by performance tier (max texture dimension)
const SIZE_LIMITS = {
low: 512,
medium: 1024,
high: 2048
};
// Memory budget in MB
const MEMORY_BUDGETS = {
low: 64,
medium: 128,
high: 256
};
let _currentTier = 'medium';
let _textureMemory = 0;
let _textures = new Set();
function detectTier() {
const gl = document.createElement('canvas').getContext('webgl');
if (!gl) return 'low';
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
if (!debugInfo) return 'medium';
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
if (renderer.includes('Apple')) {
return renderer.includes('M3') || renderer.includes('M2 Pro') || renderer.includes('M1 Pro')
? 'high' : 'medium';
}
if (renderer.includes('Intel') && !renderer.includes('Arc')) return 'low';
if (/Android|webOS|iPhone|iPad|iPod/i.test(navigator.userAgent)) return 'low';
return 'high';
}
function init() {
_currentTier = detectTier();
console.log(`[TextureOptimizer] Tier: ${_currentTier}, Max texture size: ${SIZE_LIMITS[_currentTier]}px`);
}
/**
* Load a texture with automatic optimization
*/
function load(url, options = {}) {
const loader = new THREE.TextureLoader();
const texture = loader.load(url, (tex) => {
optimizeTexture(tex, options);
});
_textures.add(texture);
return texture;
}
/**
* Optimize an existing texture
*/
function optimizeTexture(texture, options = {}) {
const maxSize = options.maxSize || SIZE_LIMITS[_currentTier];
const format = options.format || 'auto';
// Limit texture size
const width = texture.image?.width || texture.image?.videoWidth || 0;
const height = texture.image?.height || texture.image?.videoHeight || 0;
if (width > maxSize || height > maxSize) {
const scale = maxSize / Math.max(width, height);
const newWidth = Math.floor(width * scale);
const newHeight = Math.floor(height * scale);
// Create resized canvas
const canvas = document.createElement('canvas');
canvas.width = newWidth;
canvas.height = newHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(texture.image, 0, 0, newWidth, newHeight);
texture.image = canvas;
texture.needsUpdate = true;
console.log(`[TextureOptimizer] Resized texture from ${width}x${height} to ${newWidth}x${newHeight}`);
}
// Mipmap settings based on tier
if (_currentTier === 'low') {
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
} else if (_currentTier === 'medium') {
texture.generateMipmaps = true;
texture.minFilter = THREE.LinearMipmapLinearFilter;
}
// Anisotropic filtering
if (options.anisotropy !== undefined) {
texture.anisotropy = options.anisotropy;
} else if (_currentTier === 'low') {
texture.anisotropy = 1;
} else if (_currentTier === 'medium') {
texture.anisotropy = 4;
} else {
texture.anisotropy = 8;
}
// Format conversion hint (for server-side preprocessing)
if (format === 'webp' && !urlEndsWith(texture.image?.src, '.webp')) {
console.warn(`[TextureOptimizer] Consider converting to WebP: ${texture.image?.src}`);
}
// Track memory usage
trackMemory(texture);
return texture;
}
function urlEndsWith(url, suffix) {
return url && url.toLowerCase().endsWith(suffix);
}
function trackMemory(texture) {
const width = texture.image?.width || 0;
const height = texture.image?.height || 0;
const bytesPerPixel = 4; // RGBA
const mipmaps = texture.generateMipmaps ? 1.33 : 1; // Mipmaps add ~33%
const memoryMB = (width * height * bytesPerPixel * mipmaps) / (1024 * 1024);
_textureMemory += memoryMB;
texture.userData.memoryMB = memoryMB;
const budget = MEMORY_BUDGETS[_currentTier];
if (_textureMemory > budget) {
console.warn(`[TextureOptimizer] Memory budget exceeded: ${_textureMemory.toFixed(1)}MB / ${budget}MB`);
cleanupTextures();
}
}
function cleanupTextures() {
const budget = MEMORY_BUDGETS[_currentTier];
if (_textureMemory <= budget) return;
// Sort by last used time (if available)
const sorted = Array.from(_textures).sort((a, b) => {
return (a.userData.lastUsed || 0) - (b.userData.lastUsed || 0);
});
for (const tex of sorted) {
if (_textureMemory <= budget * 0.8) break;
if (tex.userData.memoryMB) {
_textureMemory -= tex.userData.memoryMB;
tex.dispose();
_textures.delete(tex);
}
}
console.log(`[TextureOptimizer] Cleaned up textures. Memory: ${_textureMemory.toFixed(1)}MB`);
}
function getMemoryUsage() {
return {
used: _textureMemory,
budget: MEMORY_BUDGETS[_currentTier],
count: _textures.size
};
}
/**
* Audit all textures in a scene
*/
function auditScene(scene) {
const audit = {
textures: [],
totalMemory: 0,
oversized: [],
uncompressed: []
};
scene.traverse((object) => {
if (object.material) {
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.forEach(mat => {
['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'emissiveMap', 'aoMap'].forEach(mapType => {
const texture = mat[mapType];
if (texture && texture.image) {
const width = texture.image.width || 0;
const height = texture.image.height || 0;
const memoryMB = (width * height * 4) / (1024 * 1024);
audit.textures.push({
type: mapType,
size: `${width}x${height}`,
memoryMB: memoryMB.toFixed(2),
url: texture.image.src || 'generated'
});
audit.totalMemory += memoryMB;
if (width > SIZE_LIMITS[_currentTier] || height > SIZE_LIMITS[_currentTier]) {
audit.oversized.push({
type: mapType,
size: `${width}x${height}`,
limit: SIZE_LIMITS[_currentTier]
});
}
const src = texture.image.src || '';
if (src && !src.endsWith('.webp') && !src.endsWith('.ktx2') && !src.endsWith('.basis')) {
audit.uncompressed.push(src);
}
}
});
});
}
});
return audit;
}
/**
* Convert image to WebP (client-side, for runtime generated textures)
*/
async function convertToWebP(imageElement, quality = 0.85) {
const canvas = document.createElement('canvas');
canvas.width = imageElement.width;
canvas.height = imageElement.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(imageElement, 0, 0);
return new Promise((resolve) => {
canvas.toBlob((blob) => {
resolve(blob);
}, 'image/webp', quality);
});
}
return {
init,
load,
optimizeTexture,
getMemoryUsage,
auditScene,
convertToWebP,
SIZE_LIMITS,
MEMORY_BUDGETS
};
})();
window.TextureOptimizer = TextureOptimizer;