Compare commits

..

4 Commits

Author SHA1 Message Date
Alexander Whitestone
8f701aa208 feat(nexus): restore Agent Vision POV Camera Toggle (#867)
Some checks failed
CI / test (pull_request) Failing after 58s
Review Approval Gate / verify-review (pull_request) Successful in 8s
CI / validate (pull_request) Failing after 33s
Implement a camera switching system that allows viewing through any
active agent's eyes in the Nexus 3D world.

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

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

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

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

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

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

Refs #862, #819, #138
2026-04-22 03:12:32 -04:00
c97364ac13 [claude] ATLAS Cockpit: operator inspector rail and session shell (#1695) (#1696)
Some checks failed
Deploy Nexus / deploy (push) Failing after 5s
Staging Verification Gate / verify-staging (push) Failing after 5s
2026-04-22 05:19:13 +00:00
14 changed files with 1975 additions and 1891 deletions

131
app.js
View File

@@ -104,7 +104,13 @@ const orbitState = {
let flyY = 2;
// ═══ INIT ══
// ══ POV CAMERA SYSTEM ══
let povMode = false; // true when viewing through agent's eyes
let povAgentIdx = -1; // index into agents[] for POV target (-1 = none)
let savedCameraState = null; // { position: Vector3, rotation: Euler } to restore on exit
const DEFAULT_AGENT_FOV = 75; // default field-of-view for agent POV cameras
// ╡══ INIT ══╡
import {
SymbolicEngine, AgentFSM, KnowledgeGraph, Blackboard,
@@ -1334,10 +1340,10 @@ function updateNexusCommand(state) {
// ═══ AGENT PRESENCE SYSTEM ═══
function createAgentPresences() {
const agentData = [
{ id: 'timmy', name: 'TIMMY', color: NEXUS.colors.primary, pos: { x: -4, z: -4 }, station: { x: -4, z: -4 } },
{ id: 'kimi', name: 'KIMI', color: NEXUS.colors.secondary, pos: { x: 4, z: -4 }, station: { x: 4, z: -4 } },
{ id: 'claude', name: 'CLAUDE', color: NEXUS.colors.gold, pos: { x: 0, z: -6 }, station: { x: 0, z: -6 } },
{ id: 'perplexity', name: 'PERPLEXITY', color: 0x4488ff, pos: { x: -6, z: -2 }, station: { x: -6, z: -2 } },
{ id: 'timmy', name: 'TIMMY', color: NEXUS.colors.primary, pos: { x: -4, z: -4 }, station: { x: -4, z: -4 }, fov: 70 },
{ id: 'kimi', name: 'KIMI', color: NEXUS.colors.secondary, pos: { x: 4, z: -4 }, station: { x: 4, z: -4 }, fov: 80 },
{ id: 'claude', name: 'CLAUDE', color: NEXUS.colors.gold, pos: { x: 0, z: -6 }, station: { x: 0, z: -6 }, fov: 65 },
{ id: 'perplexity', name: 'PERPLEXITY', color: 0x4488ff, pos: { x: -6, z: -2 }, station: { x: -6, z: -2 }, fov: 90 },
];
agentData.forEach(data => {
@@ -1393,7 +1399,8 @@ function createAgentPresences() {
color,
station: data.station,
targetPos: new THREE.Vector3(data.pos.x, 0, data.pos.z),
wanderTimer: 0
wanderTimer: 0,
fov: data.fov || DEFAULT_AGENT_FOV,
});
});
}
@@ -1957,7 +1964,97 @@ function updateNavModeUI(mode) {
if (el) el.textContent = mode.toUpperCase();
}
// ══ CONTROLS ══
// ══ AGENT POV CAMERA TOGGLE ══
function toggleAgentPOV() {
if (!agents.length) {
addChatMessage('system', 'No agents present to observe.');
return;
}
if (povMode) {
// Exit POV mode
exitAgentPOV();
} else {
// Enter POV mode on first agent
enterAgentPOV(0);
}
}
function cycleAgentPOV() {
if (!agents.length) return;
if (!povMode) {
enterAgentPOV(0);
return;
}
const nextIdx = (povAgentIdx + 1) % agents.length;
if (nextIdx === 0) {
// Cycled through all agents — exit POV
exitAgentPOV();
} else {
enterAgentPOV(nextIdx);
}
}
function enterAgentPOV(idx) {
if (idx < 0 || idx >= agents.length) return;
// Save current camera state before switching
if (!povMode) {
savedCameraState = {
position: camera.position.clone(),
rotation: camera.rotation.clone(),
fov: camera.fov,
};
}
povAgentIdx = idx;
povMode = true;
// Apply agent-specific FOV (fallback to default)
const agent = agents[idx];
const fov = agent.fov || DEFAULT_AGENT_FOV;
camera.fov = fov;
camera.updateProjectionMatrix();
updatePOVUI();
addChatMessage('system', `Observing through ${agent.id.toUpperCase()}'s eyes. FOV: ${fov}°`);
}
function exitAgentPOV() {
if (!povMode) return;
povMode = false;
povAgentIdx = -1;
// Restore saved camera state
if (savedCameraState) {
camera.position.copy(savedCameraState.position);
camera.rotation.copy(savedCameraState.rotation);
camera.fov = savedCameraState.fov;
camera.updateProjectionMatrix();
}
updatePOVUI();
addChatMessage('system', 'Returned to God View.');
}
function updatePOVUI() {
const label = document.getElementById('pov-label');
const btn = document.getElementById('pov-toggle-btn');
if (!label || !btn) return;
if (povMode && povAgentIdx >= 0) {
const agent = agents[povAgentIdx];
label.textContent = agent.id.toUpperCase();
btn.classList.add('pov-active');
} else {
label.textContent = 'AGENT POV';
btn.classList.remove('pov-active');
}
}
// ╡══ CONTROLS ══╡
function setupControls() {
document.addEventListener('keydown', (e) => {
keys[e.key.toLowerCase()] = true;
@@ -1984,6 +2081,9 @@ function setupControls() {
if (e.key.toLowerCase() === 'v' && document.activeElement !== document.getElementById('chat-input')) {
cycleNavMode();
}
if (e.key.toLowerCase() === 'p' && document.activeElement !== document.getElementById('chat-input')) {
cycleAgentPOV();
}
if (e.key.toLowerCase() === 'f' && activePortal && !portalOverlayActive) {
activatePortal(activePortal);
}
@@ -2133,6 +2233,7 @@ function setupControls() {
document.getElementById('vision-close-btn').addEventListener('click', closeVisionOverlay);
document.getElementById('mode-toggle-btn').addEventListener('click', toggleUIMode);
document.getElementById('pov-toggle-btn').addEventListener('click', cycleAgentPOV);
document.getElementById('atlas-toggle-btn').addEventListener('click', openPortalAtlas);
document.getElementById('atlas-close-btn').addEventListener('click', closePortalAtlas);
initAtlasControls();
@@ -3370,7 +3471,21 @@ function gameLoop() {
const mode = NAV_MODES[navModeIdx];
const chatActive = document.activeElement === document.getElementById('chat-input');
if (mode === 'walk') {
// Agent POV mode overrides other camera modes
if (povMode && povAgentIdx >= 0 && agents[povAgentIdx]) {
const agent = agents[povAgentIdx];
const orbPos = agent.orb.getWorldPosition(new THREE.Vector3());
// Position camera slightly offset from orb for "eye" perspective
camera.position.copy(orbPos);
camera.position.y += 0.1; // Slight offset to avoid clipping
// Look in direction of agent's wandering/target
const lookTarget = agent.targetPos.clone();
lookTarget.y = camera.position.y;
camera.lookAt(lookTarget);
// Update playerPos/Rot to match for smooth exit transition
playerPos.copy(camera.position);
playerRot.y = Math.atan2(lookTarget.x - camera.position.x, lookTarget.z - camera.position.z);
} else if (mode === 'walk') {
if (!chatActive && !portalOverlayActive) {
const speed = 6 * delta;
const dir = new THREE.Vector3();

719
cockpit-inspector.js Normal file
View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

@@ -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>
@@ -186,6 +173,10 @@
<span class="hud-icon">👁</span>
<span class="hud-btn-label" id="mode-label">VISITOR</span>
</button>
<button id="pov-toggle-btn" class="hud-icon-btn" title="Agent POV Camera">
<span class="hud-icon">👁</span>
<span class="hud-btn-label" id="pov-label">AGENT POV</span>
</button>
<button id="atlas-toggle-btn" class="hud-icon-btn" title="Portal Atlas">
<span class="hud-icon">🌐</span>
<span class="hud-btn-label">WORLDS</span>
@@ -242,6 +233,7 @@
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat &nbsp;
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
<span id="nav-mode-hint" class="nav-mode-hint"></span>
&nbsp; <span>P</span> agent POV &nbsp;
&nbsp; <span>H</span> archive &nbsp;
<span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot"></span></span>
</div>
@@ -407,35 +399,16 @@
<div id="memory-inspect-panel" class="memory-inspect-panel" style="display:none;" aria-label="Memory Inspect Panel"></div>
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
<!-- ══════════════════════════════════════════════════════════════
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>
<!-- xterm.js for operator PTY terminal (cockpit inspector rail) — issue #1695 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js" crossorigin="anonymous"></script>
<script src="./session-manager.js"></script>
<script src="./boot.js"></script>
<script src="./cockpit.js"></script>
<script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script>
<script src="./portal-hot-reload.js"></script>
<script src="./cockpit-inspector.js"></script>
<script>
function openMemoryFilter() { renderFilterList(); document.getElementById('memory-filter').style.display = 'flex'; }
function closeMemoryFilter() { document.getElementById('memory-filter').style.display = 'none'; }

129
server.py
View File

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

294
session-manager.js Normal file
View File

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

689
style.css
View File

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