720 lines
24 KiB
JavaScript
720 lines
24 KiB
JavaScript
/**
|
|
* 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
}
|
|
})();
|