diff --git a/package.json b/package.json
index 30c510b..423e5c7 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"api:parity:json": "node scripts/check-api-contract-parity.mjs --root . --openapi openapi.json --ignore-file scripts/api-contract-parity.ignore --json",
"mc": "node scripts/mc-cli.cjs",
"mc:mcp": "node scripts/mc-mcp-server.cjs",
+ "mc:tui": "node scripts/mc-tui.cjs",
"dev": "pnpm run verify:node && next dev --hostname 127.0.0.1 --port ${PORT:-3000}",
"build": "pnpm run verify:node && next build",
"start": "pnpm run verify:node && next start --hostname 0.0.0.0 --port ${PORT:-3000}",
diff --git a/scripts/mc-tui.cjs b/scripts/mc-tui.cjs
new file mode 100755
index 0000000..f2f5f58
--- /dev/null
+++ b/scripts/mc-tui.cjs
@@ -0,0 +1,416 @@
+#!/usr/bin/env node
+/*
+ Mission Control TUI (v1)
+ - Zero dependencies (ANSI escape codes)
+ - Dashboard with agents/tasks/sessions panels
+ - Keyboard-driven refresh and navigation
+ - Trigger operations: wake agent, queue poll
+ - Graceful degradation when endpoints unavailable
+
+ Usage:
+ node scripts/mc-tui.cjs [--url ] [--api-key ] [--profile ] [--refresh ]
+*/
+
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const readline = require('node:readline');
+
+// ---------------------------------------------------------------------------
+// Config (shared with mc-cli.cjs)
+// ---------------------------------------------------------------------------
+
+function parseArgs(argv) {
+ const flags = {};
+ for (let i = 0; i < argv.length; i++) {
+ const t = argv[i];
+ if (!t.startsWith('--')) continue;
+ const key = t.slice(2);
+ const next = argv[i + 1];
+ if (!next || next.startsWith('--')) { flags[key] = true; continue; }
+ flags[key] = next;
+ i++;
+ }
+ return flags;
+}
+
+function loadProfile(name) {
+ const p = path.join(os.homedir(), '.mission-control', 'profiles', `${name}.json`);
+ try {
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
+ return {
+ url: parsed.url || process.env.MC_URL || 'http://127.0.0.1:3000',
+ apiKey: parsed.apiKey || process.env.MC_API_KEY || '',
+ cookie: parsed.cookie || process.env.MC_COOKIE || '',
+ };
+ } catch {
+ return {
+ url: process.env.MC_URL || 'http://127.0.0.1:3000',
+ apiKey: process.env.MC_API_KEY || '',
+ cookie: process.env.MC_COOKIE || '',
+ };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// HTTP client
+// ---------------------------------------------------------------------------
+
+async function api(baseUrl, apiKey, cookie, method, route) {
+ const headers = { Accept: 'application/json' };
+ if (apiKey) headers['x-api-key'] = apiKey;
+ if (cookie) headers['Cookie'] = cookie;
+ const url = `${baseUrl.replace(/\/+$/, '')}${route}`;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 8000);
+ try {
+ const res = await fetch(url, { method, headers, signal: controller.signal });
+ clearTimeout(timer);
+ if (!res.ok) return { _error: `HTTP ${res.status}` };
+ return await res.json();
+ } catch (err) {
+ clearTimeout(timer);
+ return { _error: err?.name === 'AbortError' ? 'timeout' : (err?.message || 'network error') };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ANSI helpers
+// ---------------------------------------------------------------------------
+
+const ESC = '\x1b[';
+const ansi = {
+ clear: () => process.stdout.write(`${ESC}2J${ESC}H`),
+ moveTo: (row, col) => process.stdout.write(`${ESC}${row};${col}H`),
+ bold: (s) => `${ESC}1m${s}${ESC}0m`,
+ dim: (s) => `${ESC}2m${s}${ESC}0m`,
+ green: (s) => `${ESC}32m${s}${ESC}0m`,
+ yellow: (s) => `${ESC}33m${s}${ESC}0m`,
+ red: (s) => `${ESC}31m${s}${ESC}0m`,
+ cyan: (s) => `${ESC}36m${s}${ESC}0m`,
+ magenta: (s) => `${ESC}35m${s}${ESC}0m`,
+ bgBlue: (s) => `${ESC}44m${ESC}97m${s}${ESC}0m`,
+ hideCursor: () => process.stdout.write(`${ESC}?25l`),
+ showCursor: () => process.stdout.write(`${ESC}?25h`),
+ clearLine: () => process.stdout.write(`${ESC}2K`),
+};
+
+function getTermSize() {
+ return { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 };
+}
+
+function truncate(s, maxLen) {
+ if (!s) return '';
+ return s.length > maxLen ? s.slice(0, maxLen - 1) + '\u2026' : s;
+}
+
+function pad(s, len) {
+ const str = String(s || '');
+ return str.length >= len ? str.slice(0, len) : str + ' '.repeat(len - str.length);
+}
+
+function statusColor(status) {
+ const s = String(status || '').toLowerCase();
+ if (s === 'online' || s === 'active' || s === 'done' || s === 'healthy' || s === 'completed') return ansi.green(status);
+ if (s === 'idle' || s === 'sleeping' || s === 'in_progress' || s === 'pending' || s === 'warning') return ansi.yellow(status);
+ if (s === 'offline' || s === 'error' || s === 'failed' || s === 'critical' || s === 'unhealthy') return ansi.red(status);
+ return status;
+}
+
+// ---------------------------------------------------------------------------
+// Data fetching
+// ---------------------------------------------------------------------------
+
+async function fetchDashboardData(baseUrl, apiKey, cookie) {
+ const [health, agents, tasks, tokens] = await Promise.all([
+ api(baseUrl, apiKey, cookie, 'GET', '/api/status?action=health'),
+ api(baseUrl, apiKey, cookie, 'GET', '/api/agents'),
+ api(baseUrl, apiKey, cookie, 'GET', '/api/tasks?limit=15'),
+ api(baseUrl, apiKey, cookie, 'GET', '/api/tokens?action=stats&timeframe=day'),
+ ]);
+ return { health, agents, tasks, tokens };
+}
+
+// ---------------------------------------------------------------------------
+// Rendering
+// ---------------------------------------------------------------------------
+
+function renderHeader(cols, baseUrl, healthData, refreshMs) {
+ const title = ' MISSION CONTROL ';
+ const bar = ansi.bgBlue(pad(title, cols));
+ process.stdout.write(bar + '\n');
+
+ const status = healthData?._error
+ ? ansi.red('UNREACHABLE')
+ : statusColor(healthData?.status || 'unknown');
+ const url = ansi.dim(baseUrl);
+ const refresh = ansi.dim(`refresh: ${refreshMs / 1000}s`);
+ const time = ansi.dim(new Date().toLocaleTimeString());
+ process.stdout.write(` ${status} ${url} ${refresh} ${time}\n`);
+}
+
+function renderAgentsPanel(agentsData, cols, maxRows) {
+ process.stdout.write('\n' + ansi.bold(ansi.cyan(' AGENTS')) + '\n');
+
+ if (agentsData?._error) {
+ process.stdout.write(ansi.dim(` (unavailable: ${agentsData._error})\n`));
+ return;
+ }
+
+ const agents = agentsData?.agents || agentsData || [];
+ if (!Array.isArray(agents) || agents.length === 0) {
+ process.stdout.write(ansi.dim(' (no agents)\n'));
+ return;
+ }
+
+ const nameW = 18;
+ const roleW = 14;
+ const statusW = 12;
+ const header = ansi.dim(` ${pad('Name', nameW)} ${pad('Role', roleW)} ${pad('Status', statusW)} Last Seen`);
+ process.stdout.write(header + '\n');
+
+ const sorted = [...agents].sort((a, b) => {
+ const order = { online: 0, active: 0, idle: 1, sleeping: 2, offline: 3 };
+ return (order[a.status] ?? 4) - (order[b.status] ?? 4);
+ });
+
+ for (let i = 0; i < Math.min(sorted.length, maxRows); i++) {
+ const a = sorted[i];
+ const name = pad(truncate(a.name, nameW), nameW);
+ const role = pad(truncate(a.role, roleW), roleW);
+ const status = pad(statusColor(a.status || 'unknown'), statusW + 9); // +9 for ANSI codes
+ const lastSeen = a.last_heartbeat
+ ? ansi.dim(timeSince(a.last_heartbeat))
+ : ansi.dim('never');
+ process.stdout.write(` ${name} ${role} ${status} ${lastSeen}\n`);
+ }
+
+ if (sorted.length > maxRows) {
+ process.stdout.write(ansi.dim(` ... and ${sorted.length - maxRows} more\n`));
+ }
+}
+
+function renderTasksPanel(tasksData, cols, maxRows) {
+ process.stdout.write('\n' + ansi.bold(ansi.magenta(' TASKS')) + '\n');
+
+ if (tasksData?._error) {
+ process.stdout.write(ansi.dim(` (unavailable: ${tasksData._error})\n`));
+ return;
+ }
+
+ const tasks = tasksData?.tasks || tasksData || [];
+ if (!Array.isArray(tasks) || tasks.length === 0) {
+ process.stdout.write(ansi.dim(' (no tasks)\n'));
+ return;
+ }
+
+ const idW = 5;
+ const titleW = Math.min(35, cols - 40);
+ const statusW = 14;
+ const assignW = 14;
+ const header = ansi.dim(` ${pad('ID', idW)} ${pad('Title', titleW)} ${pad('Status', statusW)} ${pad('Assigned', assignW)}`);
+ process.stdout.write(header + '\n');
+
+ for (let i = 0; i < Math.min(tasks.length, maxRows); i++) {
+ const t = tasks[i];
+ const id = pad(String(t.id || ''), idW);
+ const title = pad(truncate(t.title, titleW), titleW);
+ const status = pad(statusColor(t.status || ''), statusW + 9);
+ const assigned = pad(truncate(t.assigned_to || '-', assignW), assignW);
+ process.stdout.write(` ${id} ${title} ${status} ${assigned}\n`);
+ }
+
+ const total = tasksData?.total || tasks.length;
+ if (total > maxRows) {
+ process.stdout.write(ansi.dim(` ... ${total} total tasks\n`));
+ }
+}
+
+function renderTokensPanel(tokensData) {
+ process.stdout.write('\n' + ansi.bold(ansi.yellow(' COSTS (24h)')) + '\n');
+
+ if (tokensData?._error) {
+ process.stdout.write(ansi.dim(` (unavailable: ${tokensData._error})\n`));
+ return;
+ }
+
+ const summary = tokensData?.summary || {};
+ const cost = summary.totalCost != null ? `$${summary.totalCost.toFixed(4)}` : '-';
+ const tokens = summary.totalTokens != null ? formatNumber(summary.totalTokens) : '-';
+ const requests = summary.requestCount != null ? formatNumber(summary.requestCount) : '-';
+
+ process.stdout.write(` Cost: ${ansi.bold(cost)} Tokens: ${tokens} Requests: ${requests}\n`);
+}
+
+function renderFooter(cols, selectedPanel, actionMessage) {
+ process.stdout.write('\n');
+ if (actionMessage) {
+ process.stdout.write(ansi.green(` ${actionMessage}\n`));
+ }
+ const keys = ansi.dim(' [r]efresh [a]gents [t]asks [w]ake agent [q]uit');
+ process.stdout.write(keys + '\n');
+}
+
+// ---------------------------------------------------------------------------
+// Utilities
+// ---------------------------------------------------------------------------
+
+function timeSince(ts) {
+ const now = Date.now();
+ const then = typeof ts === 'number' ? (ts < 1e12 ? ts * 1000 : ts) : new Date(ts).getTime();
+ const diff = Math.max(0, now - then);
+ if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
+ return `${Math.floor(diff / 86400000)}d ago`;
+}
+
+function formatNumber(n) {
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
+ return String(n);
+}
+
+// ---------------------------------------------------------------------------
+// Actions
+// ---------------------------------------------------------------------------
+
+async function wakeAgent(baseUrl, apiKey, cookie, agentsData) {
+ const agents = agentsData?.agents || agentsData || [];
+ const sleeping = agents.filter(a => a.status === 'sleeping' || a.status === 'idle' || a.status === 'offline');
+
+ if (sleeping.length === 0) return 'No sleeping/idle agents to wake';
+
+ // Wake the first sleeping agent
+ const target = sleeping[0];
+ const result = await api(baseUrl, apiKey, cookie, 'POST', `/api/agents/${target.id}/wake`);
+ if (result?._error) return `Wake failed: ${result._error}`;
+ return `Woke agent: ${target.name}`;
+}
+
+// ---------------------------------------------------------------------------
+// Main loop
+// ---------------------------------------------------------------------------
+
+async function main() {
+ const flags = parseArgs(process.argv.slice(2));
+
+ if (flags.help) {
+ console.log(`Mission Control TUI
+
+Usage:
+ node scripts/mc-tui.cjs [--url ] [--api-key ] [--profile ] [--refresh ]
+
+Keys:
+ r Refresh now
+ a Focus agents panel
+ t Focus tasks panel
+ w Wake first sleeping agent
+ q/Esc Quit
+`);
+ process.exit(0);
+ }
+
+ const profile = loadProfile(String(flags.profile || 'default'));
+ const baseUrl = flags.url ? String(flags.url) : profile.url;
+ const apiKey = flags['api-key'] ? String(flags['api-key']) : profile.apiKey;
+ const cookie = profile.cookie;
+ const refreshMs = Number(flags.refresh || 5000);
+
+ // Raw mode for keyboard input
+ if (process.stdin.isTTY) {
+ readline.emitKeypressEvents(process.stdin);
+ process.stdin.setRawMode(true);
+ }
+
+ ansi.hideCursor();
+
+ let running = true;
+ let data = { health: {}, agents: {}, tasks: {}, tokens: {} };
+ let actionMessage = '';
+ let selectedPanel = 'agents';
+
+ // Graceful shutdown
+ function cleanup() {
+ running = false;
+ ansi.showCursor();
+ ansi.clear();
+ process.exit(0);
+ }
+ process.on('SIGINT', cleanup);
+ process.on('SIGTERM', cleanup);
+
+ // Keyboard handler
+ process.stdin.on('keypress', async (str, key) => {
+ if (!key) return;
+ if (key.name === 'q' || (key.name === 'escape')) {
+ cleanup();
+ return;
+ }
+ if (key.name === 'c' && key.ctrl) {
+ cleanup();
+ return;
+ }
+ if (key.name === 'r') {
+ actionMessage = 'Refreshing...';
+ render();
+ data = await fetchDashboardData(baseUrl, apiKey, cookie);
+ actionMessage = 'Refreshed';
+ render();
+ setTimeout(() => { actionMessage = ''; render(); }, 2000);
+ }
+ if (key.name === 'a') { selectedPanel = 'agents'; render(); }
+ if (key.name === 't') { selectedPanel = 'tasks'; render(); }
+ if (key.name === 'w') {
+ actionMessage = 'Waking agent...';
+ render();
+ actionMessage = await wakeAgent(baseUrl, apiKey, cookie, data.agents);
+ render();
+ // Refresh after wake
+ data = await fetchDashboardData(baseUrl, apiKey, cookie);
+ render();
+ setTimeout(() => { actionMessage = ''; render(); }, 3000);
+ }
+ });
+
+ function render() {
+ const { cols, rows } = getTermSize();
+ ansi.clear();
+
+ renderHeader(cols, baseUrl, data.health, refreshMs);
+
+ // Calculate available rows for panels
+ const headerRows = 3;
+ const footerRows = 3;
+ const panelHeaderRows = 6; // section headers + token panel
+ const available = Math.max(4, rows - headerRows - footerRows - panelHeaderRows);
+ const agentRows = Math.floor(available * 0.45);
+ const taskRows = Math.floor(available * 0.55);
+
+ renderAgentsPanel(data.agents, cols, agentRows);
+ renderTasksPanel(data.tasks, cols, taskRows);
+ renderTokensPanel(data.tokens);
+ renderFooter(cols, selectedPanel, actionMessage);
+ }
+
+ // Initial fetch and render
+ actionMessage = 'Loading...';
+ render();
+ data = await fetchDashboardData(baseUrl, apiKey, cookie);
+ actionMessage = '';
+ render();
+
+ // Auto-refresh loop
+ while (running) {
+ await new Promise(resolve => setTimeout(resolve, refreshMs));
+ if (!running) break;
+ data = await fetchDashboardData(baseUrl, apiKey, cookie);
+ if (actionMessage === '') render(); // Don't overwrite action messages
+ }
+}
+
+main().catch(err => {
+ ansi.showCursor();
+ console.error('TUI error:', err.message);
+ process.exit(1);
+});