/* ═══════════════════════════════════════════════════════════
AETHEEL MISSION CONTROL — CLIENT APP
═══════════════════════════════════════════════════════════ */
const API = ''; // Same origin
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
// ── State ────────────────────────────────────────────────
let currentPage = 'command-center';
let sseSource = null;
let uptimeInterval = null;
let statsData = {};
let activityData = [];
// ── Router ───────────────────────────────────────────────
function getPage() {
const hash = window.location.hash.slice(1) || '/';
const routes = {
'/': 'command-center',
'/activity': 'activity',
'/sessions': 'sessions',
'/brain': 'brain',
'/productivity': 'productivity',
'/content': 'content',
'/tasks': 'tasks',
'/connections': 'connections',
'/settings': 'settings',
};
return routes[hash] || 'command-center';
}
function navigateTo(page) {
currentPage = page;
$$('.nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.page === page);
});
renderPage();
}
window.addEventListener('hashchange', () => navigateTo(getPage()));
// ── Init ─────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
navigateTo(getPage());
startSSE();
startUptimePolling();
});
// ── SSE (Real-time events) ───────────────────────────────
function startSSE() {
if (sseSource) sseSource.close();
sseSource = new EventSource(`${API}/events`);
sseSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'connected') { updateStatus(true); return; }
activityData.unshift(data);
if (activityData.length > 200) activityData = activityData.slice(0, 200);
if (currentPage === 'command-center' || currentPage === 'activity') {
const feedEl = $('#activity-feed');
if (feedEl) {
feedEl.insertAdjacentHTML('afterbegin', renderActivityItem(data));
while (feedEl.children.length > 100) feedEl.removeChild(feedEl.lastChild);
}
}
if (currentPage === 'command-center') fetchStats();
} catch { /* ignore */ }
};
sseSource.onerror = () => {
updateStatus(false);
setTimeout(() => { if (sseSource?.readyState === EventSource.CLOSED) startSSE(); }, 5000);
};
}
function updateStatus(online) {
const dot = $('.status-dot');
const text = $('.status-text');
if (dot) dot.classList.toggle('offline', !online);
if (text) text.textContent = online ? 'Agent Online' : 'Disconnected';
}
// ── Uptime Polling ───────────────────────────────────────
function startUptimePolling() {
fetchStats();
if (uptimeInterval) clearInterval(uptimeInterval);
uptimeInterval = setInterval(fetchStats, 10000);
}
async function fetchStats() {
try {
const res = await fetch(`${API}/api/stats`);
statsData = await res.json();
const uptimeEl = $('#xp-uptime');
const fillEl = $('#xp-fill');
if (uptimeEl) uptimeEl.textContent = statsData.uptimeFormatted;
const hours = statsData.uptimeMs / 3600000;
const pct = Math.min(100, (hours / 24) * 100);
if (fillEl) fillEl.style.width = pct + '%';
updateStatCards();
} catch { /* ignore */ }
}
function updateStatCards() {
const els = {
'stat-messages': statsData.messagesHandled,
'stat-heartbeats': statsData.heartbeats,
'stat-cron': statsData.cronRuns,
'stat-uptime': statsData.uptimeFormatted,
};
for (const [id, val] of Object.entries(els)) {
const el = document.getElementById(id);
if (el) el.textContent = val ?? 0;
}
}
// ── API Helpers ──────────────────────────────────────────
async function apiFetch(path) {
const res = await fetch(`${API}${path}`);
return res.json();
}
async function apiPost(path, data) {
const res = await fetch(`${API}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
async function apiDelete(path) {
const res = await fetch(`${API}${path}`, { method: 'DELETE' });
return res.json();
}
// ── Page Renderer ────────────────────────────────────────
async function renderPage() {
const container = $('#page-container');
container.innerHTML = '
';
switch (currentPage) {
case 'command-center': return renderCommandCenter(container);
case 'activity': return renderActivity(container);
case 'sessions': return renderSessions(container);
case 'brain': return renderBrain(container);
case 'productivity': return renderProductivity(container);
case 'content': return renderContentIntel(container);
case 'tasks': return renderTasks(container);
case 'connections': return renderConnections(container);
case 'settings': return renderSettings(container);
}
}
// ═══════════════════════════════════════════════════════════
// COMMAND CENTER
// ═══════════════════════════════════════════════════════════
async function renderCommandCenter(el) {
const [stats, activity, config] = await Promise.all([
apiFetch('/api/stats'),
apiFetch('/api/activity?count=30'),
apiFetch('/api/config'),
]);
statsData = stats;
activityData = activity;
el.innerHTML = `
Messages Handled
${stats.messagesHandled}
+${stats.messagesHandled} total
Heartbeats
${stats.heartbeats}
pulsing
Cron Runs
${stats.cronRuns}
scheduled
Agent Uptime
${stats.uptimeFormatted}
online
${activity.map(renderActivityItem).join('')}
${activity.length === 0 ? '
' : ''}
${renderConfigRow('Backend', config.agentBackend)}
${renderConfigRow('CLI Path', config.backendCliPath)}
${renderConfigRow('Model', config.backendModel)}
${renderConfigRow('Max Turns', config.backendMaxTurns)}
${renderConfigRow('Timeout', (config.queryTimeoutMs / 1000) + 's')}
${renderConfigRow('Max Concurrent', config.maxConcurrentQueries)}
${renderConfigRow('Queue Depth', config.maxQueueDepth)}
${renderConfigRow('Permission Mode', config.permissionMode)}
${renderConfigRow('Output Channel', config.outputChannelId)}
`;
}
// ═══════════════════════════════════════════════════════════
// ACTIVITY
// ═══════════════════════════════════════════════════════════
async function renderActivity(el) {
const activity = await apiFetch('/api/activity?count=100');
activityData = activity;
el.innerHTML = `
${activity.map(renderActivityItem).join('')}
${activity.length === 0 ? '
📡
No events yet — activity will appear here as the agent processes events
' : ''}
`;
}
// ═══════════════════════════════════════════════════════════
// SESSIONS
// ═══════════════════════════════════════════════════════════
async function renderSessions(el) {
const [sessions, channels] = await Promise.all([
apiFetch('/api/sessions'),
apiFetch('/api/messages'),
]);
el.innerHTML = `
Active Sessions
${sessions.length}
Channels with History
${channels.length}
${sessions.length > 0 ? `
${sessions.map(s => `
${s.sessionId.slice(0, 16)}…
`).join('')}
` : '
'}
${channels.length > 0 ? `
${channels.map(c => `
`).join('')}
` : ''}
`;
}
async function viewChannelMessages(channelId) {
const messages = await apiFetch(`/api/messages?channelId=${channelId}`);
const viewer = $('#message-viewer');
if (!viewer) return;
viewer.innerHTML = `
${messages.map(m => `
${m.direction === 'inbound' ? '↓' : '↑'}
${escapeHtml(m.sender)}
${escapeHtml(m.content.slice(0, 200))}
${formatTime(m.timestamp)}
`).join('')}
`;
}
window.viewChannelMessages = viewChannelMessages;
// ═══════════════════════════════════════════════════════════
// BRAIN (Second Brain)
// ═══════════════════════════════════════════════════════════
async function renderBrain(el) {
const [facts, memory, persona, skills] = await Promise.all([
apiFetch('/api/brain'),
apiFetch('/api/memory'),
apiFetch('/api/persona'),
apiFetch('/api/skills'),
]);
const categories = [...new Set(facts.map(f => f.category))];
el.innerHTML = `
Stored Facts
${facts.length}
indexed
Categories
${categories.length}
organized
Skills
${skills.length}
loaded
Memory Lines
${(memory.content || '').split('\n').filter(l => l.trim()).length}
stored
${skills.length > 0 ? `
${skills.map(s => `
${escapeHtml(s.name)}
${escapeHtml(s.content.slice(0, 200))}
`).join('')}
` : '
⚡
No skills loaded — add SKILL.md files to config/skills/
'}
`;
}
let selectedBrainType = 'note';
function selectBrainType(btn) {
$$('.type-tab').forEach(t => t.classList.remove('active'));
btn.classList.add('active');
selectedBrainType = btn.dataset.type;
const input = $('#brain-content-input');
const placeholders = {
note: 'Type a fact, paste a URL, or describe a file reference…',
url: 'Paste a URL (e.g. https://example.com/article)',
file: 'File path or reference (e.g. docs/api-spec.md)',
};
input.placeholder = placeholders[selectedBrainType] || placeholders.note;
}
window.selectBrainType = selectBrainType;
async function addBrainFact() {
const content = $('#brain-content-input')?.value?.trim();
if (!content) return;
const category = $('#brain-category-input')?.value?.trim() || 'general';
const tagsStr = $('#brain-tags-input')?.value?.trim() || '';
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
await apiPost('/api/brain', {
content,
type: selectedBrainType,
category,
tags,
});
// Reset inputs
$('#brain-content-input').value = '';
$('#brain-category-input').value = '';
$('#brain-tags-input').value = '';
// Refresh facts list
const facts = await apiFetch('/api/brain');
const listEl = $('#brain-facts-list');
if (listEl) listEl.innerHTML = renderBrainFacts(facts);
}
window.addBrainFact = addBrainFact;
async function deleteBrainFact(id) {
await apiDelete(`/api/brain?id=${id}`);
const facts = await apiFetch('/api/brain');
const listEl = $('#brain-facts-list');
if (listEl) listEl.innerHTML = renderBrainFacts(facts);
}
window.deleteBrainFact = deleteBrainFact;
async function searchBrainFacts() {
const query = $('#brain-search')?.value?.trim() || '';
const url = query ? `/api/brain?q=${encodeURIComponent(query)}` : '/api/brain';
const facts = await apiFetch(url);
const listEl = $('#brain-facts-list');
if (listEl) listEl.innerHTML = renderBrainFacts(facts);
}
window.searchBrainFacts = searchBrainFacts;
function renderBrainFacts(facts) {
if (facts.length === 0) return '🧠
No facts stored yet — add your first knowledge item above
';
return facts.map(f => `
${f.type === 'url' ? `
${escapeHtml(f.content)}` : escapeHtml(f.content)}
${f.tags.length > 0 ? `
${f.tags.map(t => `${escapeHtml(t)}`).join('')}
` : ''}
${formatTime(f.createdAt)}
`).join('');
}
function switchBrainTab(btn, tab) {
$$('.brain-tab').forEach(t => t.classList.remove('active'));
btn.classList.add('active');
['facts', 'memory', 'persona', 'skills'].forEach(t => {
const el = document.getElementById(`brain-${t}`);
if (el) el.style.display = t === tab ? 'block' : 'none';
});
}
window.switchBrainTab = switchBrainTab;
async function saveMemory() {
const btn = $('#save-memory-btn');
const content = $('#memory-editor').value;
btn.textContent = 'Saving…';
await apiPost('/api/memory', { content });
btn.textContent = '✓ Saved';
btn.classList.add('saved');
setTimeout(() => { btn.textContent = 'Save Memory'; btn.classList.remove('saved'); }, 2000);
}
window.saveMemory = saveMemory;
async function savePersona() {
const btn = $('#save-persona-btn');
const content = $('#persona-editor').value;
btn.textContent = 'Saving…';
await apiPost('/api/persona', { content });
btn.textContent = '✓ Saved';
btn.classList.add('saved');
setTimeout(() => { btn.textContent = 'Save Persona'; btn.classList.remove('saved'); }, 2000);
}
window.savePersona = savePersona;
// ═══════════════════════════════════════════════════════════
// PRODUCTIVITY
// ═══════════════════════════════════════════════════════════
async function renderProductivity(el) {
const tasks = await apiFetch('/api/tasks');
const byStatus = { todo: [], 'in-progress': [], done: [], archived: [] };
tasks.forEach(t => {
if (byStatus[t.status]) byStatus[t.status].push(t);
});
const projects = [...new Set(tasks.map(t => t.project))];
el.innerHTML = `
To Do
${byStatus.todo.length}
queued
In Progress
${byStatus['in-progress'].length}
active
Done
${byStatus.done.length}
completed
Projects
${projects.length}
tracked
${renderTaskColumn('📥 To Do', byStatus.todo, 'todo')}
${renderTaskColumn('🔄 In Progress', byStatus['in-progress'], 'in-progress')}
${renderTaskColumn('✅ Done', byStatus.done, 'done')}
`;
}
function renderTaskColumn(title, tasks, status) {
return `
${title}
${tasks.length}
${tasks.length > 0 ? tasks.map(t => renderProductivityCard(t)).join('') : `
No tasks
`}
`;
}
function renderProductivityCard(task) {
const priorityColors = { low: 'green', medium: 'blue', high: 'orange', urgent: 'red' };
const nextStatus = { todo: 'in-progress', 'in-progress': 'done', done: 'archived' };
const nextLabel = { todo: 'Start →', 'in-progress': 'Done ✓', done: 'Archive' };
return `
${escapeHtml(task.title)}
${task.description ? `
${escapeHtml(task.description)}
` : ''}
${task.dueDate ? `
📅 ${task.dueDate}
` : ''}
`;
}
async function addProductivityTask() {
const title = $('#task-title')?.value?.trim();
if (!title) return;
await apiPost('/api/tasks', {
title,
description: $('#task-description')?.value?.trim() || '',
priority: $('#task-priority')?.value || 'medium',
project: $('#task-project')?.value?.trim() || 'default',
dueDate: $('#task-due')?.value || null,
});
renderPage();
}
window.addProductivityTask = addProductivityTask;
async function moveTask(id, newStatus) {
await apiPost('/api/tasks/update', { id, status: newStatus });
renderPage();
}
window.moveTask = moveTask;
async function deleteProductivityTask(id) {
await apiDelete(`/api/tasks?id=${id}`);
renderPage();
}
window.deleteProductivityTask = deleteProductivityTask;
// ═══════════════════════════════════════════════════════════
// CONTENT INTEL
// ═══════════════════════════════════════════════════════════
async function renderContentIntel(el) {
const items = await apiFetch('/api/content');
const byStatus = { queued: [], read: [], archived: [] };
items.forEach(i => {
if (byStatus[i.status]) byStatus[i.status].push(i);
});
const typeIcons = {
article: '📄', video: '🎬', tweet: '🐦', paper: '📑', repo: '💻', other: '📎',
};
el.innerHTML = `
Queued
${byStatus.queued.length}
to read
Read
${byStatus.read.length}
processed
Total
${items.length}
items
`;
}
function renderContentItems(items, typeIcons) {
if (items.length === 0) return '📰
No content saved yet — add articles, papers, and more above
';
return items.map(item => `
${item.summary ? `
${escapeHtml(item.summary)}
` : ''}
${item.tags.length > 0 ? `
${item.tags.map(t => `${escapeHtml(t)}`).join('')}
` : ''}
`).join('');
}
async function addContentItem() {
const title = $('#content-title')?.value?.trim();
const url = $('#content-url')?.value?.trim();
if (!title && !url) return;
await apiPost('/api/content', {
title: title || url,
url: url || '',
type: $('#content-type')?.value || 'article',
source: $('#content-source')?.value?.trim() || 'manual',
summary: $('#content-summary')?.value?.trim() || '',
tags: ($('#content-tags')?.value || '').split(',').map(t => t.trim()).filter(Boolean),
});
// Reset
['content-title', 'content-url', 'content-source', 'content-tags', 'content-summary'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
const items = await apiFetch('/api/content');
const listEl = $('#content-list');
if (listEl) listEl.innerHTML = renderContentItems(items, { article: '📄', video: '🎬', tweet: '🐦', paper: '📑', repo: '💻', other: '📎' });
}
window.addContentItem = addContentItem;
async function updateContent(id, status) {
await apiPost('/api/content/update', { id, status });
renderPage();
}
window.updateContent = updateContent;
async function deleteContent(id) {
await apiDelete(`/api/content?id=${id}`);
renderPage();
}
window.deleteContent = deleteContent;
async function searchContent() {
const query = $('#content-search')?.value?.trim() || '';
const url = query ? `/api/content?q=${encodeURIComponent(query)}` : '/api/content';
const items = await apiFetch(url);
const listEl = $('#content-list');
if (listEl) listEl.innerHTML = renderContentItems(items, { article: '📄', video: '🎬', tweet: '🐦', paper: '📑', repo: '💻', other: '📎' });
}
window.searchContent = searchContent;
// ═══════════════════════════════════════════════════════════
// TASKS (Scheduler)
// ═══════════════════════════════════════════════════════════
async function renderTasks(el) {
const [cron, heartbeats, hooks] = await Promise.all([
apiFetch('/api/cron'),
apiFetch('/api/heartbeats'),
apiFetch('/api/hooks'),
]);
const allTasks = [
...cron.map(j => ({ ...j, taskType: 'cron', schedule: j.expression })),
...heartbeats.map(h => ({ ...h, taskType: 'heartbeat', schedule: `every ${h.intervalSeconds}s` })),
];
el.innerHTML = `
Heartbeat Checks
${heartbeats.length}
Hooks Defined
${Object.keys(hooks).filter(k => hooks[k]).length}
${allTasks.length > 0 ? `
${allTasks.map(t => `
${t.taskType}
${escapeHtml(t.name)}
${escapeHtml(t.instruction)}
${escapeHtml(t.schedule)}
`).join('')}
` : '
⏰
No scheduled tasks — define them in agents.md and heartbeat.md
'}
${Object.keys(hooks).filter(k => hooks[k]).length > 0 ? `
${Object.entries(hooks).filter(([, v]) => v).map(([hookType, instruction]) => `
hook
${escapeHtml(hookType)}
${escapeHtml(instruction)}
`).join('')}
` : ''}
`;
}
// ═══════════════════════════════════════════════════════════
// CONNECTIONS
// ═══════════════════════════════════════════════════════════
async function renderConnections(el) {
const connections = await apiFetch('/api/connections');
const active = connections.filter(c => c.status === 'active').length;
const iconMap = {
discord: '🎮', claude: '🤖', codex: '💻', gemini: '✨',
opencode: '🔧', heartbeat: '💚', cron: '🕐', ipc: '📨', skills: '⚡',
};
el.innerHTML = `
${active} / ${connections.length} connected
${Math.round((active / connections.length) * 100)}%
${connections.map(c => `
${iconMap[c.logo] || '🔌'}
${escapeHtml(c.name)}
${escapeHtml(c.detail)}
${c.status}
`).join('')}
`;
}
// ═══════════════════════════════════════════════════════════
// SETTINGS
// ═══════════════════════════════════════════════════════════
async function renderSettings(el) {
const config = await apiFetch('/api/config');
const todos = JSON.parse(localStorage.getItem('mc-todos') || '[]');
el.innerHTML = `
${Object.entries(config).map(([k, v]) => renderConfigRow(formatConfigKey(k), v)).join('')}
`;
}
function addTodo() {
const input = $('#todo-input');
const text = input.value.trim();
if (!text) return;
const todos = JSON.parse(localStorage.getItem('mc-todos') || '[]');
todos.push({ text, done: false });
localStorage.setItem('mc-todos', JSON.stringify(todos));
input.value = '';
renderPage();
}
window.addTodo = addTodo;
function toggleTodo(index) {
const todos = JSON.parse(localStorage.getItem('mc-todos') || '[]');
if (todos[index]) {
todos[index].done = !todos[index].done;
localStorage.setItem('mc-todos', JSON.stringify(todos));
renderPage();
}
}
window.toggleTodo = toggleTodo;
function deleteTodo(index) {
const todos = JSON.parse(localStorage.getItem('mc-todos') || '[]');
todos.splice(index, 1);
localStorage.setItem('mc-todos', JSON.stringify(todos));
renderPage();
}
window.deleteTodo = deleteTodo;
function renderTodoItem(todo, index) {
return `
${escapeHtml(todo.text)}
`;
}
// ═══════════════════════════════════════════════════════════
// SHARED RENDER HELPERS
// ═══════════════════════════════════════════════════════════
function renderActivityItem(item) {
const icons = { message: '💬', heartbeat: '💚', cron: '🕐', hook: '🪝', webhook: '🌐' };
return `
${icons[item.type] || '⚡'}
${capitalize(item.type)} — ${escapeHtml(item.source)}
${escapeHtml(item.detail)}
${formatTime(item.timestamp)}
`;
}
function renderConfigRow(key, value) {
return `
${escapeHtml(key)}
${escapeHtml(String(value ?? '—'))}
`;
}
function formatConfigKey(key) {
return key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()).trim();
}
// ═══════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
function formatTime(isoStr) {
try {
const d = new Date(isoStr);
const now = new Date();
const diffMs = now - d;
if (diffMs < 60000) return `${Math.floor(diffMs / 1000)}s ago`;
if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m ago`;
if (diffMs < 86400000) return `${Math.floor(diffMs / 3600000)}h ago`;
return d.toLocaleDateString();
} catch {
return '';
}
}