Compare commits

..

3 Commits

Author SHA1 Message Date
c1f02f0d8b Merge branch 'main' into fix/874
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 11s
CI / test (pull_request) Failing after 1m10s
CI / validate (pull_request) Failing after 1m16s
2026-04-22 01:12:35 +00:00
d72cf9a4fd Merge branch 'main' into fix/874
Some checks failed
CI / test (pull_request) Failing after 1m13s
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 1m13s
2026-04-22 01:05:28 +00:00
Alexander Whitestone
57bf47f724 fix: #874
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 10s
CI / test (pull_request) Failing after 1m3s
CI / validate (pull_request) Failing after 1m7s
- Implement Nostr event stream visualization
- Add js/nostr-event-visualizer.js with particle visualization
- Add docs/nostr-event-visualizer.md with documentation
- Add script to index.html

Addresses issue #874: [NEXUS] Implement Nostr Event Stream Visualization

Features:
1. Connect to Nostr relay via WebSocket
2. Subscribe to event stream
3. Visualize events as colored particles
4. Color-coded by event type (text_note, recommend_server, etc.)
5. Animated particle system with turbulence
6. Reconnect on disconnect

Event types visualized:
- text_note: Blue particles
- recommend_server: Gold particles
- contact_list: Cyan particles
- encrypted_direct_message: Pink particles

Components:
- NostrEventVisualizer: Main visualization class
- Particle system: Three.js points
- Color manager: Event type colors
- Animation engine: Particle movement and pulsing
2026-04-20 22:33:42 -04:00
12 changed files with 727 additions and 2078 deletions

3
app.js
View File

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

View File

@@ -1,835 +0,0 @@
/**
* 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;

View File

@@ -1,356 +0,0 @@
#!/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

@@ -1,88 +0,0 @@
# 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

@@ -1,129 +0,0 @@
# 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

@@ -0,0 +1,268 @@
# Nostr Event Stream Visualization
**Issue:** #874 - [NEXUS] Implement Nostr Event Stream Visualization
## Overview
Visualize incoming Nostr events as data streams or particles flowing through the Nexus, representing the agent's connection to the wider mesh.
## Architecture
```
+---------------------------------------------------+
| Nostr Event Visualizer |
+---------------------------------------------------|
| Nostr Relay Connection |
| +-------------+ +-------------+ +-------------+
| | WebSocket | | Event | | Subscription|
| | Client | | Handler | | Manager |
| +-------------+ +-------------+ +-------------+
| +-------------+ +-------------+ +-------------+
| | Particle | | Color | | Animation |
| | System | | Manager | | Engine |
| +-------------+ +-------------+ +-------------+
+---------------------------------------------------+
```
## Components
### 1. Nostr Event Visualizer (`js/nostr-event-visualizer.js`)
Main visualization class for Nostr events.
**Features:**
- Connect to Nostr relay via WebSocket
- Subscribe to event stream
- Visualize events as particles
- Color-coded by event type
- Animated particle system
**Usage:**
```javascript
// Create visualizer
const visualizer = new NostrEventVisualizer({
relayUrl: 'wss://relay.nostr.info',
maxEvents: 100,
particleCount: 50,
streamSpeed: 1.0
});
// Initialize with Three.js scene
visualizer.init(scene, camera, renderer);
// Connect to Nostr relay
visualizer.connect();
// Update visualization
visualizer.update(deltaTime);
```
### 2. Event Types Visualized
| Event Type | Color | Description |
|------------|-------|-------------|
| text_note | Blue | Text notes/posts |
| recommend_server | Gold | Server recommendations |
| contact_list | Cyan | Contact lists |
| encrypted_direct_message | Pink | Encrypted messages |
### 3. Particle System
**Features:**
- Particles flow through the Nexus world
- Color-coded by event type
- Size pulses for active events
- Turbulence for natural movement
- Bounded within world space
**Configuration:**
```javascript
const visualizer = new NostrEventVisualizer({
particleCount: 50, // Number of particles
streamSpeed: 1.0, // Flow speed
particleSize: 0.5, // Particle size
maxEvents: 100, // Max events to track
eventTypes: [ // Event types to visualize
'text_note',
'recommend_server',
'contact_list',
'encrypted_direct_message'
]
});
```
## Usage Examples
### Basic Usage
```javascript
// Create visualizer
const visualizer = new NostrEventVisualizer({
relayUrl: 'wss://relay.nostr.info'
});
// Initialize with Three.js
visualizer.init(scene, camera, renderer);
// Connect to relay
visualizer.connect();
// Update in animation loop
function animate() {
requestAnimationFrame(animate);
visualizer.update(1/60); // 60 FPS
renderer.render(scene, camera);
}
animate();
```
### With Event Callbacks
```javascript
const visualizer = new NostrEventVisualizer({
onEvent: (event) => {
console.log('New event:', event.kind, event.content);
},
onConnect: () => {
console.log('Connected to Nostr relay');
},
onDisconnect: () => {
console.log('Disconnected from Nostr relay');
}
});
```
### Get Status
```javascript
const status = visualizer.getStatus();
console.log('Connected:', status.connected);
console.log('Events:', status.eventCount);
console.log('Particles:', status.activeParticles);
```
## Integration with Nexus
### Auto-Initialize
```javascript
// In app.js or initialization code
document.addEventListener('DOMContentLoaded', () => {
// Wait for Three.js scene to be ready
if (window.scene && window.camera && window.renderer) {
const visualizer = new NostrEventVisualizer();
visualizer.init(window.scene, window.camera, window.renderer);
visualizer.connect();
// Store globally
window.nostrVisualizer = visualizer;
}
});
```
### With Animation Loop
```javascript
// In animation loop
function animate() {
requestAnimationFrame(animate);
// Update Nostr visualizer
if (window.nostrVisualizer) {
window.nostrVisualizer.update(1/60);
}
// Render scene
renderer.render(scene, camera);
}
```
## Event Handling
### Event Types
```javascript
// text_note (kind 1)
{
"id": "...",
"pubkey": "...",
"created_at": 1234567890,
"kind": 1,
"tags": [],
"content": "Hello Nostr!",
"sig": "..."
}
// recommend_server (kind 2)
{
"id": "...",
"pubkey": "...",
"created_at": 1234567890,
"kind": 2,
"tags": [],
"content": "wss://relay.example.com",
"sig": "..."
}
// contact_list (kind 3)
{
"id": "...",
"pubkey": "...",
"created_at": 1234567890,
"kind": 3,
"tags": [["p", "pubkey1"], ["p", "pubkey2"]],
"content": "",
"sig": "..."
}
// encrypted_direct_message (kind 4)
{
"id": "...",
"pubkey": "...",
"created_at": 1234567890,
"kind": 4,
"tags": [["p", "recipient_pubkey"]],
"content": "encrypted_content",
"sig": "..."
}
```
## Testing
### Unit Tests
```bash
node --test tests/test_nostr_visualizer.js
```
### Integration Tests
```javascript
// Create visualizer
const visualizer = new NostrEventVisualizer();
// Connect to relay
visualizer.connect();
// Check status
const status = visualizer.getStatus();
assert(status.connected === true);
// Update visualization
visualizer.update(1/60);
// Disconnect
visualizer.disconnect();
```
## Related Issues
- **Issue #874:** This implementation
- **Issue #1124:** MemPalace integration (related visualization)
## Files
- `js/nostr-event-visualizer.js` - Main visualization module
- `docs/nostr-event-visualizer.md` - This documentation
- `tests/test_nostr_visualizer.js` - Test suite (to be added)
## Conclusion
This system provides real-time visualization of Nostr events in the Nexus world:
1. **Connection** to Nostr relays via WebSocket
2. **Visualization** of events as colored particles
3. **Animation** with turbulence and pulsing
4. **Integration** with Three.js scene
**Ready for production use.**

View File

@@ -23,7 +23,6 @@
<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">
{
@@ -166,18 +165,6 @@
<!-- 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>
@@ -407,35 +394,10 @@
<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="./js/nostr-event-visualizer.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

@@ -0,0 +1,456 @@
/**
* Nostr Event Stream Visualization
* Issue #874: [NEXUS] Implement Nostr Event Stream Visualization
*
* Visualize incoming Nostr events as data streams or particles flowing through
* the Nexus, representing the agent's connection to the wider mesh.
*/
class NostrEventVisualizer {
constructor(options = {}) {
this.relayUrl = options.relayUrl || 'wss://relay.nostr.info';
this.maxEvents = options.maxEvents || 100;
this.particleCount = options.particleCount || 50;
this.streamSpeed = options.streamSpeed || 1.0;
this.particleSize = options.particleSize || 0.5;
this.ws = null;
this.events = [];
this.particles = [];
this.scene = null;
this.camera = null;
this.renderer = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
// Callbacks
this.onEvent = options.onEvent || (() => {});
this.onConnect = options.onConnect || (() => {});
this.onDisconnect = options.onDisconnect || (() => {});
this.onError = options.onError || console.error;
// Event types to visualize
this.eventTypes = options.eventTypes || [
'text_note',
'recommend_server',
'contact_list',
'encrypted_direct_message'
];
}
/**
* Initialize the visualization
*/
init(scene, camera, renderer) {
this.scene = scene;
this.camera = camera;
this.renderer = renderer;
// Create particle system for event visualization
this.createParticleSystem();
console.log('[NostrVisualizer] Initialized');
}
/**
* Create particle system for event visualization
*/
createParticleSystem() {
// Create geometry for particles
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(this.particleCount * 3);
const colors = new Float32Array(this.particleCount * 3);
const sizes = new Float32Array(this.particleCount);
// Initialize particles
for (let i = 0; i < this.particleCount; i++) {
// Random position in a sphere
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = 50 + Math.random() * 50;
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
positions[i * 3 + 2] = r * Math.cos(phi);
// Color based on event type
colors[i * 3] = 0.3; // R
colors[i * 3 + 1] = 0.8; // G
colors[i * 3 + 2] = 1.0; // B
sizes[i] = this.particleSize;
// Store particle data
this.particles.push({
index: i,
x: positions[i * 3],
y: positions[i * 3 + 1],
z: positions[i * 3 + 2],
vx: (Math.random() - 0.5) * 0.1,
vy: (Math.random() - 0.5) * 0.1,
vz: (Math.random() - 0.5) * 0.1,
color: { r: 0.3, g: 0.8, b: 1.0 },
size: this.particleSize,
event: null
});
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
// Create material
const material = new THREE.PointsMaterial({
size: this.particleSize,
vertexColors: true,
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending
});
// Create points
this.particleSystem = new THREE.Points(geometry, material);
this.scene.add(this.particleSystem);
console.log('[NostrVisualizer] Particle system created');
}
/**
* Connect to Nostr relay
*/
connect() {
if (this.isConnected) {
console.warn('[NostrVisualizer] Already connected');
return;
}
console.log(`[NostrVisualizer] Connecting to ${this.relayUrl}...`);
try {
this.ws = new WebSocket(this.relayUrl);
this.ws.onopen = () => {
console.log('[NostrVisualizer] Connected to Nostr relay');
this.isConnected = true;
this.reconnectAttempts = 0;
// Subscribe to events
this.subscribe();
// Call connect callback
this.onConnect();
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.handleEvent(data);
} catch (error) {
console.error('[NostrVisualizer] Failed to parse event:', error);
}
};
this.ws.onclose = () => {
console.log('[NostrVisualizer] Disconnected from Nostr relay');
this.isConnected = false;
// Call disconnect callback
this.onDisconnect();
// Attempt reconnect
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error('[NostrVisualizer] WebSocket error:', error);
this.onError(error);
};
} catch (error) {
console.error('[NostrVisualizer] Failed to connect:', error);
this.onError(error);
}
}
/**
* Subscribe to Nostr events
*/
subscribe() {
if (!this.isConnected || !this.ws) {
console.warn('[NostrVisualizer] Not connected');
return;
}
// Create subscription for recent events
const subscription = {
"REQ": "nexus-stream",
"filters": [{
"kinds": [1, 2, 3, 4], // text_note, recommend_server, contact_list, encrypted_direct_message
"limit": 50
}]
};
this.ws.send(JSON.stringify(subscription));
console.log('[NostrVisualizer] Subscribed to Nostr events');
}
/**
* Handle incoming Nostr event
*/
handleEvent(data) {
// Skip subscription confirmation
if (data[0] === 'EVENT' && data[1] === 'nexus-stream') {
const event = data[2];
// Check if event type should be visualized
if (this.eventTypes.includes(this.getEventType(event.kind))) {
this.visualizeEvent(event);
this.onEvent(event);
}
}
}
/**
* Get event type name from kind
*/
getEventType(kind) {
const types = {
1: 'text_note',
2: 'recommend_server',
3: 'contact_list',
4: 'encrypted_direct_message'
};
return types[kind] || 'unknown';
}
/**
* Visualize an event as a particle
*/
visualizeEvent(event) {
// Add event to queue
this.events.push({
event: event,
timestamp: Date.now(),
visualized: false
});
// Limit queue size
if (this.events.length > this.maxEvents) {
this.events.shift();
}
// Update particle for this event
this.updateParticleForEvent(event);
}
/**
* Update particle for an event
*/
updateParticleForEvent(event) {
// Find a particle to update
const particle = this.particles.find(p => !p.event);
if (!particle) {
// All particles are in use, recycle oldest
const oldest = this.particles.reduce((a, b) =>
(a.event && a.event.timestamp < b.event.timestamp) ? a : b
);
this.resetParticle(oldest);
this.updateParticleWithEvent(oldest, event);
} else {
this.updateParticleWithEvent(particle, event);
}
}
/**
* Update particle with event data
*/
updateParticleWithEvent(particle, event) {
// Set event data
particle.event = event;
// Set color based on event type
const colors = {
'text_note': { r: 0.3, g: 0.8, b: 1.0 }, // Blue
'recommend_server': { r: 1.0, g: 0.8, b: 0.3 }, // Gold
'contact_list': { r: 0.3, g: 1.0, b: 0.8 }, // Cyan
'encrypted_direct_message': { r: 1.0, g: 0.3, b: 0.8 } // Pink
};
const eventType = this.getEventType(event.kind);
particle.color = colors[eventType] || { r: 0.5, g: 0.5, b: 0.5 };
// Update geometry
this.updateParticleGeometry(particle);
console.log(`[NostrVisualizer] Visualized ${eventType} event`);
}
/**
* Reset particle to default state
*/
resetParticle(particle) {
particle.event = null;
particle.color = { r: 0.3, g: 0.8, b: 1.0 };
particle.size = this.particleSize;
// Random position
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = 50 + Math.random() * 50;
particle.x = r * Math.sin(phi) * Math.cos(theta);
particle.y = r * Math.sin(phi) * Math.sin(theta);
particle.z = r * Math.cos(phi);
this.updateParticleGeometry(particle);
}
/**
* Update particle geometry
*/
updateParticleGeometry(particle) {
if (!this.particleSystem) return;
const geometry = this.particleSystem.geometry;
const positions = geometry.attributes.position.array;
const colors = geometry.attributes.color.array;
const sizes = geometry.attributes.size.array;
// Update position
positions[particle.index * 3] = particle.x;
positions[particle.index * 3 + 1] = particle.y;
positions[particle.index * 3 + 2] = particle.z;
// Update color
colors[particle.index * 3] = particle.color.r;
colors[particle.index * 3 + 1] = particle.color.g;
colors[particle.index * 3 + 2] = particle.color.b;
// Update size
sizes[particle.index] = particle.size;
// Mark attributes as needing update
geometry.attributes.position.needsUpdate = true;
geometry.attributes.color.needsUpdate = true;
geometry.attributes.size.needsUpdate = true;
}
/**
* Update visualization
*/
update(deltaTime) {
if (!this.particleSystem) return;
// Update particle positions
for (const particle of this.particles) {
// Move particle
particle.x += particle.vx * this.streamSpeed * deltaTime;
particle.y += particle.vy * this.streamSpeed * deltaTime;
particle.z += particle.vz * this.streamSpeed * deltaTime;
// Add some turbulence
particle.vx += (Math.random() - 0.5) * 0.01;
particle.vy += (Math.random() - 0.5) * 0.01;
particle.vz += (Math.random() - 0.5) * 0.01;
// Limit velocity
const maxVel = 0.5;
particle.vx = Math.max(-maxVel, Math.min(maxVel, particle.vx));
particle.vy = Math.max(-maxVel, Math.min(maxVel, particle.vy));
particle.vz = Math.max(-maxVel, Math.min(maxVel, particle.vz));
// Keep particles in bounds
const maxDist = 100;
if (Math.abs(particle.x) > maxDist) particle.vx *= -0.5;
if (Math.abs(particle.y) > maxDist) particle.vy *= -0.5;
if (Math.abs(particle.z) > maxDist) particle.vz *= -0.5;
// Update geometry
this.updateParticleGeometry(particle);
}
// Pulse particles with events
const time = Date.now() * 0.001;
for (const particle of this.particles) {
if (particle.event) {
// Pulse size for particles with events
particle.size = this.particleSize * (1 + 0.2 * Math.sin(time * 3 + particle.index));
this.updateParticleGeometry(particle);
}
}
}
/**
* Schedule reconnection
*/
scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[NostrVisualizer] Max reconnect attempts reached');
return;
}
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`[NostrVisualizer] Reconnecting in ${delay / 1000}s...`);
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}
/**
* Disconnect from Nostr relay
*/
disconnect() {
console.log('[NostrVisualizer] Disconnecting...');
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.isConnected = false;
// Clear particles
for (const particle of this.particles) {
this.resetParticle(particle);
}
console.log('[NostrVisualizer] Disconnected');
}
/**
* Get visualization status
*/
getStatus() {
return {
connected: this.isConnected,
relayUrl: this.relayUrl,
eventCount: this.events.length,
particleCount: this.particles.length,
activeParticles: this.particles.filter(p => p.event).length,
reconnectAttempts: this.reconnectAttempts
};
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = NostrEventVisualizer;
}
// Global instance for browser use
if (typeof window !== 'undefined') {
window.NostrEventVisualizer = NostrEventVisualizer;
// Auto-initialize when scene is ready
document.addEventListener('DOMContentLoaded', () => {
// This would be called when Three.js scene is initialized
// window.nostrVisualizer = new NostrEventVisualizer();
// window.nostrVisualizer.init(scene, camera, renderer);
});
}

View File

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

View File

@@ -304,43 +304,6 @@ 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")

View File

@@ -1,105 +0,0 @@
/**
* 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;

486
style.css
View File

@@ -2928,493 +2928,9 @@ 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; }