Compare commits

...

20 Commits

Author SHA1 Message Date
Alexander Whitestone
b64dbdd980 feat: build operator inspector rail and session shell patterns (#1695)
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 10s
CI / test (pull_request) Failing after 52s
CI / validate (pull_request) Failing after 52s
Implements the Atlas cockpit operator surface for the Nexus:

- cockpit.js: frontend operator cockpit module
  - InspectorRail: right-side panel with SESSION/FILES/MEMORY/AGENT tabs
  - SessionStore: session taxonomy (group/tag/pin/archive) via localStorage
    + server sync to cockpit_pty.py
  - GitStatusWidget: branch + dirty badge in workspace HUD
  - CockpitTerminal: xterm.js terminal wired to native PTY via WebSocket
  - Cockpit controller: WS lifecycle, git polling (15s), panel toggles

- cockpit_pty.py: native PTY relay server (ws://127.0.0.1:8766)
  - pty_start / pty_input / pty_resize / pty_exit messages
  - git_status: branch, dirty flag, staged/unstaged/untracked counts,
    ahead/behind upstream
  - session_save / session_load: durable session persistence to disk

- index.html: cockpit toolbar (git badge + INSPECT + SHELL buttons),
  inspector rail mount point, terminal panel, xterm.js CDN scripts

- style.css: inspector rail slide-in, tab nav, session cards,
  git status badges (clean/dirty/error), terminal panel, agent health dots

- docs/ADR-cockpit-shell-boundary.md: ADR-001 choosing native local PTY +
  xterm.js over SSH PTY / ttyd / browser-only pseudo-terminal

- docs/ATLAS_COCKPIT_PATTERNS.md: documents patterns adopted, adapted,
  and rejected from dodo-reach/hermes-desktop, outsourc-e/hermes-workspace,
  nesquena/hermes-webui

Fixes #1695

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 01:22:14 -04: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
e8d7e987e5 Merge pull request 'fix: [SESSION] Add in-world transcript/history viewer backed by harness logs' (#1688) from mimo/code/issue-708 into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 12s
Staging Verification Gate / verify-staging (push) Failing after 12s
Merge PR #1688: fix: [SESSION] Add in-world transcript/history viewer backed by harness logs
2026-04-22 01:04:23 +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
12 changed files with 2385 additions and 6 deletions

3
app.js
View File

@@ -734,6 +734,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.');

835
cockpit.js Normal file
View File

@@ -0,0 +1,835 @@
/**
* cockpit.js — Nexus Operator Cockpit
*
* Provides the operator-facing control surface for the Nexus:
* - Inspector/right rail (SESSION | FILES | MEMORY | AGENT tabs)
* - Session taxonomy (group / tag / pin / archive)
* - Git/dirty/unsaved-state indicator in workspace header
* - Shell terminal panel via cockpit_pty.py + xterm.js
*
* Connects to: ws://127.0.0.1:8766 (cockpit_pty.py)
* Patterns sourced from: dodo-reach/hermes-desktop, outsourc-e/hermes-workspace,
* nesquena/hermes-webui. See docs/ATLAS_COCKPIT_PATTERNS.md.
*
* Refs: #1695
*/
'use strict';
// ─── Constants ──────────────────────────────────────────────────────────────
const COCKPIT_WS_URL = 'ws://127.0.0.1:8766';
const GIT_POLL_INTERVAL_MS = 15000; // 15s
const SESSION_STORE_KEY = 'nexus_cockpit_sessions';
// ─── SessionStore ────────────────────────────────────────────────────────────
// Local-first session taxonomy: group / tag / pin / archive.
// Persists to localStorage (fast) and syncs to cockpit_pty.py (durable).
// Pattern: adapted from nesquena/hermes-webui session list model.
class SessionStore {
constructor() {
this._sessions = this._load();
this._listeners = [];
}
// ── Persistence ─────────────────────────────────────────────────────────
_load() {
try {
const raw = localStorage.getItem(SESSION_STORE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
_save() {
try {
localStorage.setItem(SESSION_STORE_KEY, JSON.stringify(this._sessions));
} catch (e) {
console.warn('[cockpit] localStorage save failed:', e);
}
this._emit();
}
replaceAll(sessions) {
this._sessions = sessions;
this._save();
}
// ── CRUD ─────────────────────────────────────────────────────────────────
create(name, { group = '', tags = [], pinned = false } = {}) {
const session = {
id: `sess_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
name,
group,
tags: Array.isArray(tags) ? tags : [],
pinned: Boolean(pinned),
archived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
this._sessions.unshift(session);
this._save();
return session;
}
get(id) {
return this._sessions.find(s => s.id === id) || null;
}
update(id, patch) {
const idx = this._sessions.findIndex(s => s.id === id);
if (idx === -1) return null;
this._sessions[idx] = { ...this._sessions[idx], ...patch, updatedAt: new Date().toISOString() };
this._save();
return this._sessions[idx];
}
delete(id) {
const before = this._sessions.length;
this._sessions = this._sessions.filter(s => s.id !== id);
if (this._sessions.length !== before) this._save();
}
// ── Taxonomy actions ─────────────────────────────────────────────────────
pin(id) { return this.update(id, { pinned: true }); }
unpin(id) { return this.update(id, { pinned: false }); }
archive(id) { return this.update(id, { archived: true, pinned: false }); }
unarchive(id) { return this.update(id, { archived: false }); }
setGroup(id, group) { return this.update(id, { group }); }
addTag(id, tag) {
const s = this.get(id);
if (!s) return null;
const tags = Array.from(new Set([...s.tags, tag]));
return this.update(id, { tags });
}
removeTag(id, tag) {
const s = this.get(id);
if (!s) return null;
return this.update(id, { tags: s.tags.filter(t => t !== tag) });
}
// ── Queries ──────────────────────────────────────────────────────────────
listActive() {
return this._sessions
.filter(s => !s.archived)
.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.updatedAt.localeCompare(a.updatedAt));
}
listArchived() {
return this._sessions.filter(s => s.archived);
}
listByGroup(group) {
return this.listActive().filter(s => s.group === group);
}
listByTag(tag) {
return this.listActive().filter(s => s.tags.includes(tag));
}
listPinned() {
return this.listActive().filter(s => s.pinned);
}
allGroups() {
return Array.from(new Set(this._sessions.filter(s => s.group).map(s => s.group)));
}
allTags() {
return Array.from(new Set(this._sessions.flatMap(s => s.tags)));
}
getAll() {
return [...this._sessions];
}
// ── Observer ─────────────────────────────────────────────────────────────
onChange(fn) { this._listeners.push(fn); }
_emit() { this._listeners.forEach(fn => fn(this._sessions)); }
}
// ─── GitStatusWidget ─────────────────────────────────────────────────────────
// Shows branch name + dirty badge in the workspace header.
// Pattern: adapted from outsourc-e/hermes-workspace git badge.
class GitStatusWidget {
constructor(containerEl, cockpitWs) {
this.el = containerEl;
this.ws = cockpitWs;
this._status = null;
this._render({ branch: '…', dirty: false, error: null });
}
update(status) {
this._status = status;
this._render(status);
}
_render({ branch, dirty, ahead, behind, staged, unstaged, untracked, error }) {
if (!this.el) return;
if (error) {
this.el.innerHTML = `<span class="git-badge git-badge--error" title="${error}">git: err</span>`;
return;
}
const aheadBehind = (ahead || behind)
? ` <span class="git-ahead-behind">↑${ahead || 0}${behind || 0}</span>`
: '';
const dirtyCount = (staged || 0) + (unstaged || 0) + (untracked || 0);
const dirtyBadge = dirty
? `<span class="git-dirty-badge" title="${staged}S ${unstaged}M ${untracked}?">●${dirtyCount}</span>`
: `<span class="git-clean-badge">✓</span>`;
this.el.innerHTML = `
<span class="git-badge ${dirty ? 'git-badge--dirty' : 'git-badge--clean'}">
<span class="git-branch-icon">⎇</span>
<span class="git-branch-name">${branch}</span>
${dirtyBadge}${aheadBehind}
</span>`;
}
}
// ─── CockpitTerminal ─────────────────────────────────────────────────────────
// xterm.js terminal wired to cockpit_pty.py over WebSocket.
// Pattern: adopted from dodo-reach/hermes-desktop terminal panel.
class CockpitTerminal {
constructor(containerEl, cockpitWs) {
this.containerEl = containerEl;
this.ws = cockpitWs;
this.term = null;
this.fitAddon = null;
this._started = false;
}
async init() {
if (!window.Terminal || !window.FitAddon) {
console.warn('[cockpit] xterm.js not loaded — terminal panel unavailable');
if (this.containerEl) {
this.containerEl.innerHTML = `
<div class="terminal-unavailable">
<span class="terminal-unavailable-icon">⊘</span>
<span>Terminal unavailable — xterm.js not loaded</span>
</div>`;
}
return;
}
this.term = new window.Terminal({
cursorBlink: true,
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
fontSize: 13,
lineHeight: 1.4,
theme: {
background: '#0a0c10',
foreground: '#d0f0ff',
cursor: '#4af0c0',
selectionBackground: 'rgba(74,240,192,0.25)',
black: '#0d1117',
red: '#ff5f87',
green: '#4af0c0',
yellow: '#f0e04a',
blue: '#7b5cff',
magenta: '#d07bff',
cyan: '#4af0c0',
white: '#d0f0ff',
brightBlack: '#4a5568',
brightRed: '#ff7b9c',
brightGreen: '#7bffd4',
brightYellow: '#ffe87b',
brightBlue: '#9d80ff',
brightMagenta: '#e5a0ff',
brightCyan: '#7bffd4',
brightWhite: '#ffffff',
},
});
const { FitAddon } = window.FitAddon || {};
if (FitAddon) {
this.fitAddon = new FitAddon();
this.term.loadAddon(this.fitAddon);
}
this.term.open(this.containerEl);
if (this.fitAddon) this.fitAddon.fit();
// Forward terminal input to PTY
this.term.onData(data => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'pty_input',
data: btoa(unescape(encodeURIComponent(data))),
}));
}
});
// Resize observer
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => {
if (this.fitAddon) {
this.fitAddon.fit();
const { cols, rows } = this.term;
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'pty_resize', cols, rows }));
}
}
});
ro.observe(this.containerEl);
}
this._started = false;
}
startSession(cwd) {
if (!this.term || this._started) return;
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
const { cols, rows } = this.term;
this.ws.send(JSON.stringify({ type: 'pty_start', cols, rows, cwd }));
this._started = true;
}
handleOutput(b64data) {
if (!this.term) return;
try {
const data = decodeURIComponent(escape(atob(b64data)));
this.term.write(data);
} catch {
// Raw fallback for binary
const bytes = Uint8Array.from(atob(b64data), c => c.charCodeAt(0));
this.term.write(bytes);
}
}
handleExit(code) {
if (!this.term) return;
this.term.writeln(`\r\n\x1b[33m[shell exited: ${code}]\x1b[0m`);
this._started = false;
}
focus() {
if (this.term) this.term.focus();
}
}
// ─── InspectorRail ───────────────────────────────────────────────────────────
// Right-side panel with tabs: SESSION | FILES | MEMORY | AGENT
// Pattern: adapted from outsourc-e/hermes-workspace inspector rail (simplified 6→4 tabs).
class InspectorRail {
constructor(railEl, sessionStore, gitWidget, terminal) {
this.el = railEl;
this.store = sessionStore;
this.gitWidget = gitWidget;
this.terminal = terminal;
this._activeTab = 'session';
this._agentHealth = null;
this._init();
}
_init() {
if (!this.el) return;
this.el.innerHTML = `
<div class="rail-header">
<div class="rail-tabs" role="tablist">
<button class="rail-tab active" data-tab="session" role="tab" aria-selected="true">SESSION</button>
<button class="rail-tab" data-tab="files" role="tab">FILES</button>
<button class="rail-tab" data-tab="memory" role="tab">MEMORY</button>
<button class="rail-tab" data-tab="agent" role="tab">AGENT</button>
</div>
<button class="rail-close-btn" id="rail-close-btn" title="Close inspector" aria-label="Close inspector">✕</button>
</div>
<div class="rail-body">
<div class="rail-pane" id="rail-pane-session" role="tabpanel"></div>
<div class="rail-pane" id="rail-pane-files" role="tabpanel" style="display:none;"></div>
<div class="rail-pane" id="rail-pane-memory" role="tabpanel" style="display:none;"></div>
<div class="rail-pane" id="rail-pane-agent" role="tabpanel" style="display:none;"></div>
</div>`;
// Tab switching
this.el.querySelectorAll('.rail-tab').forEach(btn => {
btn.addEventListener('click', () => this._switchTab(btn.dataset.tab));
});
// Close button
const closeBtn = this.el.querySelector('#rail-close-btn');
if (closeBtn) closeBtn.addEventListener('click', () => Cockpit.hideInspector());
// Session store observer
this.store.onChange(() => {
if (this._activeTab === 'session') this._renderSession();
});
this._renderSession();
this._renderFiles();
this._renderMemory();
this._renderAgent();
}
_switchTab(tab) {
this._activeTab = tab;
this.el.querySelectorAll('.rail-tab').forEach(btn => {
const active = btn.dataset.tab === tab;
btn.classList.toggle('active', active);
btn.setAttribute('aria-selected', active);
});
this.el.querySelectorAll('.rail-pane').forEach(pane => {
pane.style.display = pane.id === `rail-pane-${tab}` ? '' : 'none';
});
if (tab === 'session') this._renderSession();
if (tab === 'agent') this._renderAgent();
}
// ── Session pane ─────────────────────────────────────────────────────────
_renderSession() {
const pane = this.el.querySelector('#rail-pane-session');
if (!pane) return;
const pinned = this.store.listPinned();
const active = this.store.listActive().filter(s => !s.pinned);
const archived = this.store.listArchived();
const groups = this.store.allGroups();
const tags = this.store.allTags();
pane.innerHTML = `
<div class="rail-section">
<div class="rail-section-header">
<span>Sessions</span>
<button class="rail-action-btn" id="sess-new-btn">+ New</button>
</div>
${pinned.length ? `
<div class="sess-group-label">📌 Pinned</div>
${pinned.map(s => this._sessionCard(s)).join('')}` : ''}
${active.length ? `
<div class="sess-group-label">Active</div>
${active.map(s => this._sessionCard(s)).join('')}` : ''}
${!pinned.length && !active.length ? `<div class="rail-empty">No active sessions. Create one to get started.</div>` : ''}
</div>
${archived.length ? `
<div class="rail-section">
<details class="sess-archive-details">
<summary class="sess-archive-summary">Archived (${archived.length})</summary>
${archived.map(s => this._sessionCard(s)).join('')}
</details>
</div>` : ''}
${groups.length ? `
<div class="rail-section">
<div class="rail-section-header">Groups</div>
${groups.map(g => `<span class="sess-group-tag" data-group="${g}">${g}</span>`).join('')}
</div>` : ''}
${tags.length ? `
<div class="rail-section">
<div class="rail-section-header">Tags</div>
${tags.map(t => `<span class="sess-tag-badge" data-tag="${t}">#${t}</span>`).join('')}
</div>` : ''}`;
// New session button
pane.querySelector('#sess-new-btn')?.addEventListener('click', () => this._createSession());
// Card actions
pane.querySelectorAll('[data-sess-action]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const { sessAction, sessId } = btn.dataset;
this._sessAction(sessAction, sessId);
});
});
// Group / tag filter clicks
pane.querySelectorAll('[data-group]').forEach(el => {
el.addEventListener('click', () => this._switchTab('session'));
});
}
_sessionCard(s) {
const tagBadges = s.tags.map(t => `<span class="sess-tag-mini">#${t}</span>`).join('');
const groupBadge = s.group ? `<span class="sess-group-mini">${s.group}</span>` : '';
const pinnedIcon = s.pinned ? '📌 ' : '';
const archivedIcon = s.archived ? '🗄 ' : '';
return `
<div class="sess-card ${s.pinned ? 'sess-card--pinned' : ''} ${s.archived ? 'sess-card--archived' : ''}" data-id="${s.id}">
<div class="sess-card-name">${pinnedIcon}${archivedIcon}${this._esc(s.name)}</div>
<div class="sess-card-meta">${groupBadge}${tagBadges}</div>
<div class="sess-card-actions">
${s.pinned
? `<button class="sess-act-btn" data-sess-action="unpin" data-sess-id="${s.id}" title="Unpin">📌</button>`
: `<button class="sess-act-btn" data-sess-action="pin" data-sess-id="${s.id}" title="Pin">📍</button>`}
${s.archived
? `<button class="sess-act-btn" data-sess-action="unarchive" data-sess-id="${s.id}" title="Restore">↩</button>`
: `<button class="sess-act-btn" data-sess-action="archive" data-sess-id="${s.id}" title="Archive">🗄</button>`}
<button class="sess-act-btn" data-sess-action="delete" data-sess-id="${s.id}" title="Delete">✕</button>
</div>
</div>`;
}
_sessAction(action, id) {
switch (action) {
case 'pin': this.store.pin(id); break;
case 'unpin': this.store.unpin(id); break;
case 'archive': this.store.archive(id); break;
case 'unarchive': this.store.unarchive(id); break;
case 'delete':
if (confirm('Delete this session?')) this.store.delete(id);
break;
}
this._renderSession();
}
_createSession() {
const name = prompt('Session name:');
if (!name || !name.trim()) return;
const group = prompt('Group (optional — leave blank for none):') || '';
const tagsRaw = prompt('Tags (comma-separated, optional):') || '';
const tags = tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
this.store.create(name.trim(), { group, tags });
this._renderSession();
}
// ── Files pane ───────────────────────────────────────────────────────────
_renderFiles() {
const pane = this.el.querySelector('#rail-pane-files');
if (!pane) return;
pane.innerHTML = `
<div class="rail-section">
<div class="rail-section-header">Workspace Files</div>
<div class="rail-empty rail-empty--hint">
File browser is populated by the active session context.<br>
Open a session and use the terminal to navigate.
</div>
</div>
<div class="rail-section">
<div class="rail-section-header">Recent Artifacts</div>
<div id="rail-artifacts-list" class="rail-artifact-list">
<div class="rail-empty">No artifacts yet.</div>
</div>
</div>`;
}
addArtifact(name, type, ref) {
const list = this.el.querySelector('#rail-artifacts-list');
if (!list) return;
const empty = list.querySelector('.rail-empty');
if (empty) empty.remove();
const item = document.createElement('div');
item.className = 'rail-artifact-item';
item.innerHTML = `
<span class="artifact-type-badge artifact-type--${type}">${type}</span>
<span class="artifact-name">${this._esc(name)}</span>
${ref ? `<a class="artifact-ref" href="${this._esc(ref)}" target="_blank" rel="noopener">↗</a>` : ''}`;
list.prepend(item);
}
// ── Memory pane ──────────────────────────────────────────────────────────
_renderMemory() {
const pane = this.el.querySelector('#rail-pane-memory');
if (!pane) return;
pane.innerHTML = `
<div class="rail-section">
<div class="rail-section-header">Memory References</div>
<div class="rail-empty rail-empty--hint">
Memory entries appear here when Timmy surfaces them.<br>
Interact with the 3D memory palace to populate.
</div>
</div>
<div class="rail-section">
<div class="rail-section-header">Skills</div>
<div id="rail-skills-list" class="rail-skills-list">
<div class="rail-empty">No skills registered.</div>
</div>
</div>`;
}
addMemoryRef(key, summary) {
const section = this.el.querySelector('#rail-pane-memory .rail-section');
if (!section) return;
const item = document.createElement('div');
item.className = 'rail-mem-item';
item.innerHTML = `<span class="mem-key">${this._esc(key)}</span><span class="mem-summary">${this._esc(summary)}</span>`;
section.appendChild(item);
}
// ── Agent pane ───────────────────────────────────────────────────────────
_renderAgent() {
const pane = this.el.querySelector('#rail-pane-agent');
if (!pane) return;
const h = this._agentHealth;
pane.innerHTML = `
<div class="rail-section">
<div class="rail-section-header">Agent Health</div>
<div class="agent-health-card">
<div class="agent-health-row">
<span class="agent-health-label">Timmy</span>
<span class="agent-health-dot ${h?.timmy === 'ok' ? 'dot--ok' : h?.timmy === 'warn' ? 'dot--warn' : 'dot--unknown'}"></span>
<span class="agent-health-status">${h?.timmy || 'unknown'}</span>
</div>
<div class="agent-health-row">
<span class="agent-health-label">Hermes WS</span>
<span class="agent-health-dot ${h?.ws === 'ok' ? 'dot--ok' : 'dot--unknown'}"></span>
<span class="agent-health-status">${h?.ws || 'unknown'}</span>
</div>
<div class="agent-health-row">
<span class="agent-health-label">Cockpit PTY</span>
<span class="agent-health-dot ${h?.pty === 'ok' ? 'dot--ok' : 'dot--unknown'}"></span>
<span class="agent-health-status">${h?.pty || 'unknown'}</span>
</div>
<div class="agent-health-row">
<span class="agent-health-label">Bannerlord</span>
<span class="agent-health-dot ${h?.bannerlord === 'ok' ? 'dot--ok' : h?.bannerlord === 'warn' ? 'dot--warn' : 'dot--unknown'}"></span>
<span class="agent-health-status">${h?.bannerlord || 'unknown'}</span>
</div>
</div>
</div>
<div class="rail-section">
<div class="rail-section-header">Session Info</div>
<div class="agent-session-info">
<div class="agent-info-row"><span>Active sessions</span><span>${this.store.listActive().length}</span></div>
<div class="agent-info-row"><span>Pinned</span><span>${this.store.listPinned().length}</span></div>
<div class="agent-info-row"><span>Archived</span><span>${this.store.listArchived().length}</span></div>
<div class="agent-info-row"><span>Groups</span><span>${this.store.allGroups().length}</span></div>
<div class="agent-info-row"><span>Tags</span><span>${this.store.allTags().length}</span></div>
</div>
</div>`;
}
updateAgentHealth(health) {
this._agentHealth = health;
if (this._activeTab === 'agent') this._renderAgent();
}
_esc(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
}
// ─── Cockpit (main controller) ────────────────────────────────────────────────
const Cockpit = (() => {
let _ws = null;
let _wsReady = false;
let _gitPollTimer = null;
let _store = null;
let _gitWidget = null;
let _terminal = null;
let _rail = null;
let _inspectorVisible = false;
// ── WebSocket lifecycle ─────────────────────────────────────────────────
function _connect() {
try {
_ws = new WebSocket(COCKPIT_WS_URL);
} catch (e) {
console.warn('[cockpit] WebSocket connection failed:', e);
return;
}
_ws.addEventListener('open', _onOpen);
_ws.addEventListener('message', _onMessage);
_ws.addEventListener('close', _onClose);
_ws.addEventListener('error', e => console.warn('[cockpit] ws error:', e));
}
function _onOpen() {
_wsReady = true;
console.info('[cockpit] Connected to cockpit_pty.py');
// Update agent health
_rail?.updateAgentHealth({ ..._rail._agentHealth, pty: 'ok' });
// Load sessions from server (merges with localStorage)
_ws.send(JSON.stringify({ type: 'session_load' }));
// Start git polling
_pollGit();
_gitPollTimer = setInterval(_pollGit, GIT_POLL_INTERVAL_MS);
// Start terminal session if terminal panel is visible
if (_terminal && document.getElementById('cockpit-terminal-panel')?.classList.contains('panel--visible')) {
_terminal.startSession(window.__NEXUS_ROOT__ || '.');
}
}
function _onMessage(event) {
let msg;
try { msg = JSON.parse(event.data); }
catch { return; }
switch (msg.type) {
case 'pty_output':
_terminal?.handleOutput(msg.data);
break;
case 'pty_exit':
_terminal?.handleExit(msg.code);
break;
case 'git_status':
_gitWidget?.update(msg);
break;
case 'session_data':
if (msg.sessions && msg.sessions.length > 0) {
// Server sessions win over local if they have more entries
const local = _store.getAll();
if (msg.sessions.length >= local.length) {
_store.replaceAll(msg.sessions);
}
}
break;
case 'error':
console.warn('[cockpit] server error:', msg.message);
break;
}
}
function _onClose() {
_wsReady = false;
clearInterval(_gitPollTimer);
_rail?.updateAgentHealth({ ..._rail._agentHealth, pty: 'unknown' });
console.info('[cockpit] Disconnected from cockpit_pty.py. Will retry in 10s.');
setTimeout(_connect, 10000);
}
function _pollGit() {
if (_ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(JSON.stringify({ type: 'git_status' }));
}
}
function _syncSessions() {
if (_ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(JSON.stringify({ type: 'session_save', sessions: _store.getAll() }));
}
}
// ── Public API ──────────────────────────────────────────────────────────
function init() {
_store = new SessionStore();
// Git status widget
const gitEl = document.getElementById('cockpit-git-status');
_gitWidget = new GitStatusWidget(gitEl, _ws);
// Inspector rail
const railEl = document.getElementById('cockpit-inspector-rail');
_rail = new InspectorRail(railEl, _store, _gitWidget, _terminal);
// Terminal
const termEl = document.getElementById('cockpit-terminal-body');
_terminal = new CockpitTerminal(termEl, _ws);
_terminal.init().catch(console.error);
// Wire toggle buttons
const inspectorToggle = document.getElementById('cockpit-inspector-toggle');
inspectorToggle?.addEventListener('click', toggleInspector);
const terminalToggle = document.getElementById('cockpit-terminal-toggle');
terminalToggle?.addEventListener('click', toggleTerminal);
const terminalStart = document.getElementById('cockpit-terminal-start');
terminalStart?.addEventListener('click', () => {
if (_terminal) {
_terminal.ws = _ws;
_terminal.startSession('.');
_terminal.focus();
}
});
// Sync sessions to server on change
_store.onChange(_syncSessions);
// Connect to cockpit_pty.py
_connect();
// Reflect WS health immediately
_rail?.updateAgentHealth({
timmy: 'unknown',
ws: document.getElementById('ws-status-dot')?.classList.contains('connected') ? 'ok' : 'unknown',
pty: 'unknown',
bannerlord: 'unknown',
});
console.info('[cockpit] Initialized.');
}
function showInspector() {
const rail = document.getElementById('cockpit-inspector-rail');
if (rail) {
rail.classList.add('rail--visible');
_inspectorVisible = true;
document.getElementById('cockpit-inspector-toggle')?.classList.add('active');
}
}
function hideInspector() {
const rail = document.getElementById('cockpit-inspector-rail');
if (rail) {
rail.classList.remove('rail--visible');
_inspectorVisible = false;
document.getElementById('cockpit-inspector-toggle')?.classList.remove('active');
}
}
function toggleInspector() {
_inspectorVisible ? hideInspector() : showInspector();
}
function showTerminal() {
const panel = document.getElementById('cockpit-terminal-panel');
if (panel) {
panel.classList.add('panel--visible');
if (_terminal && _ws?.readyState === WebSocket.OPEN && !_terminal._started) {
_terminal.ws = _ws;
_terminal.startSession('.');
}
setTimeout(() => _terminal?.focus(), 100);
}
}
function hideTerminal() {
const panel = document.getElementById('cockpit-terminal-panel');
if (panel) panel.classList.remove('panel--visible');
}
function toggleTerminal() {
const panel = document.getElementById('cockpit-terminal-panel');
if (panel?.classList.contains('panel--visible')) hideTerminal();
else showTerminal();
}
function updateAgentHealth(health) {
_rail?.updateAgentHealth(health);
}
function addArtifact(name, type, ref) {
_rail?.addArtifact(name, type, ref);
}
function addMemoryRef(key, summary) {
_rail?.addMemoryRef(key, summary);
}
return { init, showInspector, hideInspector, toggleInspector, showTerminal, hideTerminal, toggleTerminal, updateAgentHealth, addArtifact, addMemoryRef };
})();
// Auto-init when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', Cockpit.init);
} else {
Cockpit.init();
}
// Expose globally for integration with app.js
window.Cockpit = Cockpit;

356
cockpit_pty.py Normal file
View File

@@ -0,0 +1,356 @@
#!/usr/bin/env python3
"""
Nexus Cockpit PTY Server — Operator shell relay and git status.
Exposes:
ws://127.0.0.1:8766 WebSocket for PTY sessions and git status polling
Protocol (WebSocket messages, JSON):
Client -> Server:
{"type": "pty_resize", "cols": 80, "rows": 24}
{"type": "pty_input", "data": "<base64-encoded stdin>"}
{"type": "git_status"}
{"type": "session_save", "session": {...}}
{"type": "session_load"}
Server -> Client:
{"type": "pty_output", "data": "<base64-encoded stdout>"}
{"type": "pty_exit", "code": 0}
{"type": "git_status", "branch": "main", "dirty": false, "ahead": 0, "behind": 0, "staged": 0, "unstaged": 0, "untracked": 0}
{"type": "session_data", "sessions": [...]}
{"type": "error", "message": "..."}
Security: binds to 127.0.0.1 only. Never expose externally.
"""
import asyncio
import base64
import json
import logging
import os
import pty
import select
import signal
import subprocess
import sys
import fcntl
import termios
import struct
from pathlib import Path
import websockets
# Configuration
HOST = "127.0.0.1"
PORT = 8766
NEXUS_ROOT = Path(__file__).parent
SESSION_STORE_PATH = NEXUS_ROOT / ".cockpit_sessions.json"
SHELL = os.environ.get("SHELL", "/bin/bash")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [cockpit-pty] %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("cockpit-pty")
def get_git_status(cwd: Path) -> dict:
"""Return git status summary for the given directory."""
result = {
"branch": "unknown",
"dirty": False,
"ahead": 0,
"behind": 0,
"staged": 0,
"unstaged": 0,
"untracked": 0,
"error": None,
}
try:
# Branch name
branch_out = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=cwd, stderr=subprocess.DEVNULL, timeout=5
).decode().strip()
result["branch"] = branch_out
# Porcelain status
status_out = subprocess.check_output(
["git", "status", "--porcelain=v1", "-u"],
cwd=cwd, stderr=subprocess.DEVNULL, timeout=5
).decode()
staged = 0
unstaged = 0
untracked = 0
for line in status_out.splitlines():
if len(line) < 2:
continue
x, y = line[0], line[1]
if x == "?" and y == "?":
untracked += 1
else:
if x != " " and x != "?":
staged += 1
if y != " " and y != "?":
unstaged += 1
result["staged"] = staged
result["unstaged"] = unstaged
result["untracked"] = untracked
result["dirty"] = (staged + unstaged + untracked) > 0
# Ahead/behind upstream
try:
rev_out = subprocess.check_output(
["git", "rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
cwd=cwd, stderr=subprocess.DEVNULL, timeout=5
).decode().strip()
behind_str, ahead_str = rev_out.split()
result["ahead"] = int(ahead_str)
result["behind"] = int(behind_str)
except Exception:
pass # No upstream — that's fine
except subprocess.CalledProcessError as e:
result["error"] = f"git error: {e}"
except FileNotFoundError:
result["error"] = "git not found"
except Exception as e:
result["error"] = str(e)
return result
def load_sessions() -> list:
"""Load sessions from the session store."""
if SESSION_STORE_PATH.exists():
try:
return json.loads(SESSION_STORE_PATH.read_text())
except Exception:
return []
return []
def save_sessions(sessions: list) -> None:
"""Persist sessions to disk."""
SESSION_STORE_PATH.write_text(json.dumps(sessions, indent=2))
class PTYSession:
"""Manages a single PTY subprocess."""
def __init__(self, cols: int = 80, rows: int = 24, cwd: str = None):
self.cols = cols
self.rows = rows
self.cwd = cwd or str(NEXUS_ROOT)
self.master_fd: int = None
self.pid: int = None
self._reader: asyncio.StreamReader = None
self._closed = False
def spawn(self):
"""Fork a shell into a PTY."""
pid, master_fd = pty.fork()
if pid == 0:
# Child — exec the shell
os.chdir(self.cwd)
env = os.environ.copy()
env["TERM"] = "xterm-256color"
env["COLUMNS"] = str(self.cols)
env["LINES"] = str(self.rows)
os.execvpe(SHELL, [SHELL], env)
else:
self.pid = pid
self.master_fd = master_fd
self._resize(self.cols, self.rows)
def _resize(self, cols: int, rows: int):
self.cols = cols
self.rows = rows
if self.master_fd is not None:
try:
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize)
except OSError:
pass
def write_input(self, data: bytes):
"""Write raw bytes to the PTY master (stdin of the shell)."""
if self.master_fd is not None:
try:
os.write(self.master_fd, data)
except OSError:
pass
def read_output(self, timeout: float = 0.02) -> bytes:
"""Non-blocking read from PTY master (stdout of the shell)."""
if self.master_fd is None:
return b""
try:
r, _, _ = select.select([self.master_fd], [], [], timeout)
if r:
return os.read(self.master_fd, 4096)
except OSError:
pass
return b""
def close(self):
if self._closed:
return
self._closed = True
if self.pid:
try:
os.kill(self.pid, signal.SIGTERM)
except ProcessLookupError:
pass
if self.master_fd is not None:
try:
os.close(self.master_fd)
except OSError:
pass
async def handle_client(websocket: websockets.WebSocketServerProtocol):
"""Handle one browser connection: PTY relay + git status + session management."""
addr = websocket.remote_address
logger.info(f"Cockpit client connected from {addr}")
pty_session: PTYSession = None
pty_task: asyncio.Task = None
async def pty_output_loop():
"""Read PTY output in a loop and forward to browser."""
loop = asyncio.get_event_loop()
while True:
if pty_session is None or pty_session._closed:
break
# Run blocking read in executor
data = await loop.run_in_executor(None, pty_session.read_output)
if data:
try:
await websocket.send(json.dumps({
"type": "pty_output",
"data": base64.b64encode(data).decode(),
}))
except websockets.exceptions.ConnectionClosed:
break
else:
# Check if child has exited
if pty_session.pid:
try:
result = os.waitpid(pty_session.pid, os.WNOHANG)
if result[0] != 0:
code = result[1] >> 8
try:
await websocket.send(json.dumps({
"type": "pty_exit",
"code": code,
}))
except Exception:
pass
break
except ChildProcessError:
break
await asyncio.sleep(0.01)
try:
async for raw_msg in websocket:
try:
msg = json.loads(raw_msg)
except json.JSONDecodeError:
await websocket.send(json.dumps({"type": "error", "message": "Invalid JSON"}))
continue
msg_type = msg.get("type")
if msg_type == "pty_start":
cols = int(msg.get("cols", 80))
rows = int(msg.get("rows", 24))
cwd = msg.get("cwd", str(NEXUS_ROOT))
if pty_session:
pty_session.close()
if pty_task:
pty_task.cancel()
pty_session = PTYSession(cols=cols, rows=rows, cwd=cwd)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, pty_session.spawn)
pty_task = asyncio.create_task(pty_output_loop())
logger.info(f"PTY session started: pid={pty_session.pid} cols={cols} rows={rows}")
elif msg_type == "pty_input":
if pty_session:
raw = base64.b64decode(msg.get("data", ""))
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, pty_session.write_input, raw)
elif msg_type == "pty_resize":
if pty_session:
cols = int(msg.get("cols", 80))
rows = int(msg.get("rows", 24))
pty_session._resize(cols, rows)
elif msg_type == "git_status":
status = await asyncio.get_event_loop().run_in_executor(
None, get_git_status, NEXUS_ROOT
)
status["type"] = "git_status"
await websocket.send(json.dumps(status))
elif msg_type == "session_save":
sessions = msg.get("sessions", [])
await asyncio.get_event_loop().run_in_executor(
None, save_sessions, sessions
)
await websocket.send(json.dumps({"type": "session_saved", "count": len(sessions)}))
elif msg_type == "session_load":
sessions = await asyncio.get_event_loop().run_in_executor(
None, load_sessions
)
await websocket.send(json.dumps({"type": "session_data", "sessions": sessions}))
else:
await websocket.send(json.dumps({"type": "error", "message": f"Unknown type: {msg_type}"}))
except websockets.exceptions.ConnectionClosed:
logger.info(f"Cockpit client disconnected {addr}")
except Exception as e:
logger.error(f"Cockpit handler error for {addr}: {e}")
finally:
if pty_task:
pty_task.cancel()
if pty_session:
pty_session.close()
logger.info(f"Cockpit session cleaned up for {addr}")
async def main():
logger.info(f"Starting Nexus Cockpit PTY server on ws://{HOST}:{PORT}")
logger.info(f"Shell: {SHELL}")
logger.info(f"Nexus root: {NEXUS_ROOT}")
logger.info(f"Session store: {SESSION_STORE_PATH}")
stop = asyncio.get_event_loop().create_future()
def shutdown():
if not stop.done():
stop.set_result(None)
loop = asyncio.get_event_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, shutdown)
except NotImplementedError:
pass
async with websockets.serve(handle_client, HOST, PORT):
logger.info("Cockpit PTY server ready.")
await stop
logger.info("Cockpit PTY server shutdown complete.")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,88 @@
# ADR-001: Cockpit Shell Boundary and Transport Model
**Date:** 2026-04-22
**Status:** Accepted
---
## Context
The Nexus operator cockpit requires a real shell/terminal accessible from the browser UI. The cockpit is a local-first operator tool for managing and observing Timmy, an AI agent running on the operator's machine. The existing Nexus infrastructure consists of:
- `server.py` — Python WebSocket bridge on `ws://127.0.0.1:8765` serving as the main nexus broadcast bus (agent telemetry, portal state, heartbeat)
- `app.js` + `index.html` — Three.js frontend rendering the 3D world and operator HUD
A shell terminal in the cockpit would let the operator issue commands, tail logs, and interact with Timmy's runtime without leaving the browser UI.
### Options Considered
**Option 1: Native/local PTY via Python `pty` module**
Spawn a local shell process (e.g. `bash` or `zsh`) using Python's stdlib `pty` module. Stream PTY I/O over a dedicated WebSocket. Render in the browser with xterm.js.
**Option 2: Remote SSH PTY**
SSH from a backend process into localhost (or a remote host) and relay the SSH PTY stream over WebSocket.
**Option 3: Browser-only pseudo-terminal**
Implement a fake shell in JavaScript with no real process backing it — command parsing and output simulation only.
---
## Decision
**Adopt Option 1: Native/local PTY via Python `pty` module, rendered with xterm.js over a dedicated WebSocket at `ws://127.0.0.1:8766`.**
The PTY server (`cockpit_pty.py`) runs as a sidecar alongside `server.py`. It is intentionally kept on a separate port (8766) to avoid coupling shell I/O to the main nexus broadcast bus (8765). The browser-side terminal is rendered with xterm.js, loaded from CDN.
### Rationale
- **Local-first architecture match.** Nexus is explicitly a local-first tool. The operator is always on the same machine as the agent. There is no need for remote shell infrastructure.
- **Zero external dependencies for the backend.** Python's `pty` module is part of the standard library. No third-party process manager, SSH daemon, or shell relay binary is required.
- **Separation of concerns.** Keeping shell I/O on a dedicated WebSocket (8766) prevents noisy PTY data from polluting the telemetry/broadcast bus (8765). Each WebSocket has a single clear responsibility.
- **xterm.js is the canonical browser terminal renderer.** It handles ANSI escape sequences, resize events, and scrollback correctly. Loading from CDN is appropriate for a local operator tool with no offline-first requirement for the terminal component specifically.
- **Precedent from Atlas UI sources.** The terminal panel split-view pattern was mined from `dodo-reach/hermes-desktop`, which demonstrated that xterm.js + WebSocket relay is a proven, low-friction approach for agent management UIs.
---
## Consequences
### Positive
- Real shell access from the browser cockpit with minimal infrastructure.
- The PTY is a genuine OS-level shell; any CLI tool available on the operator's machine works as expected.
- The main nexus bus remains clean and single-purpose.
- xterm.js handles terminal emulation correctly without bespoke ANSI parsing code.
### Negative / Constraints
- A second server process (`cockpit_pty.py`) must be started alongside `server.py`. Operators running Nexus locally must start both, or a launcher script must manage both processes.
- The shell terminal is **only accessible when running locally.** This is intentional — the cockpit is a local operator tool, not a remote management surface. No remote access is provided by design.
- xterm.js is loaded from CDN. If the operator has no internet access at startup, the terminal panel will not render. (This is acceptable given the local-first context; the 3D world and agent telemetry do not depend on xterm.js.)
- `cockpit_pty.py` is a trusted-localhost-only server. It must not be exposed beyond `127.0.0.1`. No auth is implemented; the local-only bind address is the security boundary.
---
## Rejected Alternatives
### Option 2: SSH PTY
Rejected. SSH introduces key management overhead (keygen, authorized_keys, known_hosts) that is entirely unnecessary for a local-only tool. Connecting to localhost via SSH to relay a PTY that is already local adds latency and complexity with no benefit. Python `pty` does the same job from a single stdlib import.
### Option 3: Browser-only pseudo-terminal
Rejected. A fake shell with no real process backing it does not provide genuine shell access. The operator cockpit requires the ability to run real commands — tail logs, inspect files, restart processes — not a simulated command interface.
### ttyd / wetty
Rejected. Both are capable tools, but they are heavy external dependencies (Go binary, Node.js process) for a problem that Python's stdlib `pty` module solves directly. Introducing an external binary creates installation friction and diverges from the "minimal sidecar" model appropriate for a local operator tool.
---
## References
- `server.py` — main nexus WebSocket bridge (port 8765)
- `cockpit_pty.py` — PTY sidecar (port 8766, to be implemented)
- Atlas UI sources mined during cockpit design:
- `dodo-reach/hermes-desktop` — terminal panel UX and xterm.js integration pattern
- `outsourc-e/hermes-workspace` — inspector rail layout
- `nesquena/hermes-webui` — session taxonomy primitives
- `ATLAS_COCKPIT_PATTERNS.md` — detailed record of adopted, adapted, and rejected patterns from the above sources

View File

@@ -0,0 +1,129 @@
# ATLAS_COCKPIT_PATTERNS.md
## Nexus Operator Cockpit — Atlas UI Pattern Audit
This document catalogs which UI/UX patterns were adopted, adapted, or rejected from three Atlas source repositories when designing the Nexus operator cockpit. The Nexus cockpit is a browser-based operator terminal for managing and observing Timmy, a local-first AI agent. Its architecture is documented in `ADR-cockpit-shell-boundary.md`.
---
## Source Repositories
| Repo | Description |
|---|---|
| `dodo-reach/hermes-desktop` | Desktop Electron app for Hermes agent management |
| `outsourc-e/hermes-workspace` | Workspace management UI with inspector rails |
| `nesquena/hermes-webui` | Web UI for Hermes with session management |
---
## dodo-reach/hermes-desktop
### What Was Audited
- Terminal panel implementation (xterm.js integration, shell spawning via Electron IPC)
- Agent status indicator components in the sidebar
- Split-view panel layout (terminal pane + agent detail pane)
- Native OS notification hooks
- Electron main/renderer IPC patterns for shell relay
### Adopted
**Terminal panel split-view layout**
The resizable split-panel layout pairing a terminal pane with a detail/inspector pane was adopted directly. Nexus uses the same conceptual arrangement: xterm.js fills one panel, agent telemetry fills the adjacent panel. The resize handle behavior and panel persistence are modeled on hermes-desktop's approach. The underlying mechanism differs (WebSocket PTY vs. Electron IPC) but the visual model is the same.
**Agent status indicators in the inspector rail**
The health dot + status label pattern — a colored dot (green/amber/red) paired with a short text label — was adopted for Nexus's agent status display. hermes-desktop demonstrated this as a scannable, low-noise way to convey agent health at a glance without requiring the operator to parse log output.
### Adapted
_(none — the above adoptions were clean lifts; hermes-desktop patterns that didn't fit Nexus were rejected rather than adapted)_
### Rejected
**Electron-specific IPC for shell access**
hermes-desktop uses Electron's `ipcMain`/`ipcRenderer` bridge to relay PTY I/O between the Node.js main process and the browser renderer. This is Electron-specific infrastructure. Nexus runs in a plain browser, not Electron. The PTY relay is instead handled by `cockpit_pty.py` over WebSocket (see ADR-001). The outcome is the same; the transport is different.
**Native OS notifications**
hermes-desktop hooks into the OS notification system (via Electron's `Notification` API) to surface agent events when the window is backgrounded. Nexus does not use this. The cockpit is a focused local operator tool; the operator is expected to be watching the UI. Backgrounded notifications add complexity without a clear benefit in the Nexus usage model.
---
## outsourc-e/hermes-workspace
### What Was Audited
- Inspector right rail with tabbed section navigation
- Git status badge and repository state display
- Multi-workspace switcher and workspace metadata
- Cloud sync hooks and session persistence model
- File browser panel integration
### Adapted
**Inspector right rail with tabbed sections**
hermes-workspace uses a right inspector rail with six tabs (Overview, Files, Git, Environment, Connections, Settings). This was adapted for Nexus: simplified to four tabs — SESSION, FILES, MEMORY, AGENT — reflecting that Nexus is 3D-world-first, not workspace-first. Tabs specific to multi-workspace management and cloud connections were removed. The tab rail layout itself (fixed-width right panel, icon + label tabs, scrollable tab body) is retained.
**Git status badge**
hermes-workspace renders a full inline diff view in the Git tab, including staged/unstaged file lists and diff hunks. This was adapted for Nexus: the git state display is reduced to a branch name + dirty flag indicator (`main *` or `main`). The Nexus cockpit is not a git client; operators who need full diff inspection use their normal tools. The branch/dirty indicator is sufficient for the cockpit context.
### Rejected
**Cloud sync for sessions**
hermes-workspace syncs session state to a remote store. Nexus is local-first by design. Session state lives on disk locally. No cloud sync infrastructure is introduced.
**Multi-workspace concept**
hermes-workspace is built around switching between multiple named workspaces. Nexus has a single sovereign workspace: the local Nexus installation. The workspace switcher, workspace metadata, and associated navigation are not applicable.
---
## nesquena/hermes-webui
### What Was Audited
- Session list sidebar and session lifecycle management
- Session taxonomy model (grouping, tagging, pinning, archiving)
- OAuth login flow and user account management
- User account switcher and multi-user session isolation
### Adopted
**Session taxonomy model**
hermes-webui treats group, tag, pin, and archive as first-class session primitives with explicit UI affordances for each. This taxonomy was adopted for the Nexus session list. Sessions (Timmy interaction threads) can be grouped by context, tagged with free-form labels, pinned to the top of the list, and archived out of the active view. The four-primitive model maps cleanly to the Nexus usage pattern without modification.
**Session list sidebar pattern**
The session list sidebar layout — fixed-width left panel, chronological session list with inline metadata (last active, tag chips, pin indicator), and a new-session affordance at the top — was adopted. hermes-webui demonstrated this as an effective pattern for navigating a growing list of agent sessions. Nexus uses the same structural layout.
### Rejected
**OAuth / login flow**
hermes-webui implements a full OAuth login flow (provider selection, token exchange, session persistence tied to user identity). Nexus is a single-operator local tool. There is no authentication layer. The operator is the machine owner; the local-only bind addresses on `server.py` and `cockpit_pty.py` are the security boundary. No login flow is implemented or needed.
**User accounts and multi-user session isolation**
hermes-webui supports multiple user accounts with isolated session namespaces. Nexus operates under a single-operator model. Session ownership, user switching, and per-user isolation are not applicable concepts. All sessions belong to the local operator.
---
## Summary Table
| Pattern | Source | Decision | Notes |
|---|---|---|---|
| Terminal panel split-view layout | hermes-desktop | Adopted | xterm.js + resizable split panel |
| Agent status health dot + label | hermes-desktop | Adopted | In inspector rail |
| Electron IPC for shell relay | hermes-desktop | Rejected | Nexus uses WebSocket PTY |
| Native OS notifications | hermes-desktop | Rejected | Not needed for local cockpit |
| Inspector right rail (tabbed) | hermes-workspace | Adapted | 6 tabs → 4 (SESSION, FILES, MEMORY, AGENT) |
| Git status display | hermes-workspace | Adapted | Full diff → branch + dirty flag only |
| Cloud sync for sessions | hermes-workspace | Rejected | Local-first only |
| Multi-workspace switcher | hermes-workspace | Rejected | Single sovereign workspace |
| Session taxonomy (group/tag/pin/archive) | hermes-webui | Adopted | Direct lift |
| Session list sidebar | hermes-webui | Adopted | Direct lift |
| OAuth / login flow | hermes-webui | Rejected | No auth needed; local-only tool |
| User accounts / multi-user isolation | hermes-webui | Rejected | Single-operator model |
---
## See Also
- `ADR-cockpit-shell-boundary.md` — transport and shell boundary decision
- `server.py` — main nexus WebSocket bridge (port 8765)
- `cockpit_pty.py` — PTY sidecar (port 8766)

View File

@@ -23,6 +23,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./style.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
<link rel="manifest" href="./manifest.json">
<script type="importmap">
{
@@ -165,6 +166,18 @@
<!-- Top Right: Agent Log, Atlas & SOUL Toggle -->
<div class="hud-top-right">
<!-- Cockpit toolbar: git status + inspector + terminal toggles -->
<div class="cockpit-toolbar">
<div id="cockpit-git-status" title="Git workspace status"></div>
<button id="cockpit-inspector-toggle" class="cockpit-icon-btn" title="Toggle operator inspector rail">
<span></span>
<span>INSPECT</span>
</button>
<button id="cockpit-terminal-toggle" class="cockpit-icon-btn" title="Toggle operator terminal">
<span></span>
<span>SHELL</span>
</button>
</div>
<button id="atlas-toggle-btn" class="hud-icon-btn" title="World Directory">
<button id="soul-toggle-btn" class="hud-icon-btn" title="Timmy's SOUL">
<span class="hud-icon"></span>
@@ -394,9 +407,35 @@
<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>
<!-- ══════════════════════════════════════════════════════════════
COCKPIT — Operator Inspector Rail (issue #1695)
Pattern sources: dodo-reach/hermes-desktop, outsourc-e/hermes-workspace,
nesquena/hermes-webui
See: docs/ATLAS_COCKPIT_PATTERNS.md, docs/ADR-cockpit-shell-boundary.md
══════════════════════════════════════════════════════════════ -->
<!-- Inspector / Right Rail -->
<div id="cockpit-inspector-rail" aria-label="Operator Inspector Rail" role="complementary"></div>
<!-- Terminal Panel (slide-up from bottom) -->
<div id="cockpit-terminal-panel" aria-label="Operator Shell Terminal">
<div class="terminal-panel-header">
<span class="terminal-panel-title">⌨ OPERATOR SHELL — native PTY via cockpit_pty.py</span>
<button id="cockpit-terminal-start" class="terminal-panel-btn" title="Start shell session">▶ Start</button>
<button class="terminal-panel-close" onclick="Cockpit.hideTerminal()" title="Close terminal" aria-label="Close terminal"></button>
</div>
<div id="cockpit-terminal-body"></div>
</div>
<!-- xterm.js + FitAddon (CDN, local-first — no npm required) -->
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="./boot.js"></script>
<script src="./cockpit.js"></script>
<script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script>
<script src="./portal-hot-reload.js"></script>
<script>
function openMemoryFilter() { renderFilterList(); document.getElementById('memory-filter').style.display = 'flex'; }
function closeMemoryFilter() { document.getElementById('memory-filter').style.display = 'none'; }

View File

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

View File

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

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

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

118
server.py
View File

@@ -3,20 +3,34 @@
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 signal
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"))
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 +42,97 @@ 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
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)
@@ -81,6 +177,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

486
style.css
View File

@@ -2928,9 +2928,493 @@ body.operator-mode #mode-label {
.reasoning-trace {
width: 280px;
}
.trace-content {
max-height: 200px;
}
}
/* ═══════════════════════════════════════════════════════════════════════════
COCKPIT — Operator Inspector Rail, Git Status, Terminal Panel
Issue #1695 — Atlas cockpit patterns
═══════════════════════════════════════════════════════════════════════════ */
/* ── Cockpit toolbar buttons (placed in hud-top-right) ─────────────────── */
.cockpit-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: var(--space-2);
}
.cockpit-icon-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--panel-radius);
color: var(--color-text-muted);
font-family: var(--font-body);
font-size: var(--text-xs);
cursor: pointer;
transition: border-color var(--transition-ui), color var(--transition-ui), background var(--transition-ui);
}
.cockpit-icon-btn:hover {
border-color: var(--color-border-bright);
color: var(--color-primary);
background: rgba(74, 240, 192, 0.06);
}
.cockpit-icon-btn.active {
border-color: var(--color-primary);
color: var(--color-primary);
background: rgba(74, 240, 192, 0.1);
}
/* ── Git status badge ───────────────────────────────────────────────────── */
#cockpit-git-status {
display: inline-flex;
align-items: center;
font-family: var(--font-body);
font-size: var(--text-xs);
}
.git-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 4px;
border: 1px solid;
font-size: var(--text-xs);
white-space: nowrap;
}
.git-badge--clean {
border-color: rgba(74, 240, 192, 0.35);
color: var(--color-primary);
background: rgba(74, 240, 192, 0.06);
}
.git-badge--dirty {
border-color: rgba(255, 170, 34, 0.4);
color: var(--color-warning);
background: rgba(255, 170, 34, 0.07);
}
.git-badge--error {
border-color: rgba(255, 68, 102, 0.4);
color: var(--color-danger);
background: rgba(255, 68, 102, 0.07);
}
.git-branch-icon { opacity: 0.7; font-size: 12px; }
.git-branch-name { font-weight: 500; letter-spacing: 0.03em; }
.git-dirty-badge {
font-size: 10px;
color: var(--color-warning);
font-weight: 600;
}
.git-clean-badge { font-size: 10px; color: var(--color-primary); }
.git-ahead-behind { font-size: 10px; opacity: 0.7; margin-left: 2px; }
/* ── Inspector Rail ─────────────────────────────────────────────────────── */
#cockpit-inspector-rail {
position: fixed;
top: 0;
right: -320px; /* hidden off-screen */
width: 300px;
height: 100vh;
background: rgba(8, 12, 28, 0.96);
border-left: 1px solid var(--color-border);
backdrop-filter: blur(var(--panel-blur));
display: flex;
flex-direction: column;
z-index: 900;
transition: right var(--transition-ui);
font-size: var(--text-sm);
}
#cockpit-inspector-rail.rail--visible {
right: 0;
}
.rail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.rail-tabs {
display: flex;
gap: 2px;
}
.rail-tab {
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 4px;
background: none;
color: var(--color-text-muted);
font-family: var(--font-body);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
cursor: pointer;
transition: color var(--transition-ui), border-color var(--transition-ui), background var(--transition-ui);
}
.rail-tab:hover { color: var(--color-text); }
.rail-tab.active {
border-color: var(--color-border-bright);
color: var(--color-primary);
background: rgba(74, 240, 192, 0.07);
}
.rail-close-btn {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
transition: color var(--transition-ui);
}
.rail-close-btn:hover { color: var(--color-danger); }
.rail-body {
flex: 1;
overflow-y: auto;
padding: var(--space-3);
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.rail-pane { animation: fade-in 0.15s ease; }
@keyframes fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.rail-section {
margin-bottom: var(--space-4);
}
.rail-section-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-text-muted);
text-transform: uppercase;
margin-bottom: var(--space-2);
padding-bottom: 4px;
border-bottom: 1px solid var(--color-border);
}
.rail-action-btn {
padding: 2px 8px;
background: rgba(74, 240, 192, 0.08);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-primary);
font-family: var(--font-body);
font-size: 10px;
cursor: pointer;
transition: background var(--transition-ui), border-color var(--transition-ui);
}
.rail-action-btn:hover {
background: rgba(74, 240, 192, 0.16);
border-color: var(--color-border-bright);
}
.rail-empty {
font-size: var(--text-xs);
color: var(--color-text-muted);
font-style: italic;
padding: var(--space-2) 0;
line-height: 1.5;
}
.rail-empty--hint { opacity: 0.7; }
/* ── Session cards ──────────────────────────────────────────────────────── */
.sess-card {
background: rgba(255,255,255,0.02);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: var(--space-2) var(--space-3);
margin-bottom: 6px;
transition: border-color var(--transition-ui), background var(--transition-ui);
}
.sess-card:hover { border-color: rgba(74,240,192,0.3); background: rgba(74,240,192,0.03); }
.sess-card--pinned { border-color: rgba(74,240,192,0.25); background: rgba(74,240,192,0.04); }
.sess-card--archived { opacity: 0.6; }
.sess-card-name {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text);
margin-bottom: 4px;
word-break: break-word;
}
.sess-card-meta {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.sess-group-mini {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background: rgba(123, 92, 255, 0.15);
border: 1px solid rgba(123, 92, 255, 0.3);
color: var(--color-secondary);
}
.sess-tag-mini {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
background: rgba(74,240,192,0.07);
border: 1px solid rgba(74,240,192,0.2);
color: var(--color-primary);
}
.sess-card-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
.sess-act-btn {
padding: 2px 5px;
background: none;
border: 1px solid transparent;
border-radius: 3px;
color: var(--color-text-muted);
cursor: pointer;
font-size: 12px;
transition: color var(--transition-ui), border-color var(--transition-ui);
}
.sess-act-btn:hover { color: var(--color-primary); border-color: var(--color-border); }
.sess-group-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
margin: var(--space-2) 0 4px;
}
.sess-archive-details summary { cursor: pointer; }
.sess-archive-summary {
font-size: 11px;
color: var(--color-text-muted);
padding: 4px 0;
list-style: none;
}
.sess-group-tag, .sess-tag-badge {
display: inline-block;
margin: 2px;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
cursor: pointer;
transition: background var(--transition-ui);
}
.sess-group-tag {
background: rgba(123,92,255,0.12);
border: 1px solid rgba(123,92,255,0.25);
color: var(--color-secondary);
}
.sess-tag-badge {
background: rgba(74,240,192,0.07);
border: 1px solid rgba(74,240,192,0.2);
color: var(--color-primary);
}
/* ── Artifacts ──────────────────────────────────────────────────────────── */
.rail-artifact-list { display: flex; flex-direction: column; gap: 4px; }
.rail-artifact-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: 4px;
background: rgba(255,255,255,0.02);
border: 1px solid var(--color-border);
font-size: var(--text-xs);
}
.artifact-type-badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
font-weight: 600;
text-transform: uppercase;
}
.artifact-type--file { background: rgba(74,240,192,0.1); color: var(--color-primary); }
.artifact-type--image { background: rgba(123,92,255,0.1); color: var(--color-secondary); }
.artifact-type--report { background: rgba(255,170,34,0.1); color: var(--color-warning); }
.artifact-type--code { background: rgba(255,68,102,0.1); color: var(--color-danger); }
.artifact-name { flex: 1; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.artifact-ref { color: var(--color-primary); text-decoration: none; }
/* ── Memory ─────────────────────────────────────────────────────────────── */
.rail-mem-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 6px;
border-left: 2px solid var(--color-secondary);
margin-bottom: 4px;
background: rgba(123,92,255,0.04);
}
.mem-key { font-size: 10px; font-weight: 600; color: var(--color-secondary); text-transform: uppercase; }
.mem-summary { font-size: var(--text-xs); color: var(--color-text-muted); }
/* ── Agent health ───────────────────────────────────────────────────────── */
.agent-health-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: var(--space-2);
background: rgba(255,255,255,0.02);
border: 1px solid var(--color-border);
border-radius: 6px;
}
.agent-health-row {
display: flex;
align-items: center;
gap: 8px;
}
.agent-health-label {
flex: 1;
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.agent-health-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot--ok { background: var(--color-primary); box-shadow: 0 0 4px var(--color-primary); }
.dot--warn { background: var(--color-warning); box-shadow: 0 0 4px var(--color-warning); }
.dot--unknown { background: var(--color-text-muted); }
.agent-health-status { font-size: 10px; color: var(--color-text-muted); min-width: 50px; }
.agent-session-info { display: flex; flex-direction: column; gap: 4px; }
.agent-info-row {
display: flex;
justify-content: space-between;
font-size: var(--text-xs);
color: var(--color-text-muted);
padding: 2px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.agent-info-row span:last-child { color: var(--color-text); font-weight: 500; }
/* ── Terminal Panel ─────────────────────────────────────────────────────── */
#cockpit-terminal-panel {
position: fixed;
bottom: -100%;
left: 0;
right: 0;
height: 340px;
background: rgba(5, 8, 18, 0.97);
border-top: 1px solid var(--color-border);
backdrop-filter: blur(var(--panel-blur));
display: flex;
flex-direction: column;
z-index: 800;
transition: bottom var(--transition-ui);
}
#cockpit-terminal-panel.panel--visible {
bottom: 0;
}
.terminal-panel-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 6px var(--space-3);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.terminal-panel-title {
flex: 1;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.terminal-panel-btn {
padding: 2px 8px;
background: rgba(74,240,192,0.08);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-primary);
font-family: var(--font-body);
font-size: 10px;
cursor: pointer;
transition: background var(--transition-ui);
}
.terminal-panel-btn:hover { background: rgba(74,240,192,0.16); }
.terminal-panel-close {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
transition: color var(--transition-ui);
}
.terminal-panel-close:hover { color: var(--color-danger); }
#cockpit-terminal-body {
flex: 1;
overflow: hidden;
padding: var(--space-2);
}
.terminal-unavailable {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.terminal-unavailable-icon { font-size: 24px; opacity: 0.5; }
/* ── xterm.js overrides ─────────────────────────────────────────────────── */
.xterm { height: 100% !important; }
.xterm-viewport { border-radius: 0; }
.xterm-screen { font-feature-settings: "liga" 0; }

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()