=> {
+ const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`);
+ const pathname = url.pathname;
+
+ // API routes need auth, static files don't
+ if (pathname.startsWith("/dashboard/api/")) {
+ if (!requireAuth(req, res)) return;
+ }
+
+ // API: agent soul (parameterized)
+ if (pathname.match(/^\/dashboard\/api\/agents\/[^/]+\/soul$/)) {
+ const agentId = pathname.split("/")[4];
+ const agentsDir = "/home/openclaw/.openclaw/agents";
+ const soulPath = join(agentsDir, agentId, "SOUL.md");
+ try {
+ if (existsSync(soulPath)) {
+ jsonResponse(res, { content: readFileSync(soulPath, "utf-8") });
+ } else {
+ jsonResponse(res, { content: null });
+ }
+ } catch (e: any) { jsonResponse(res, { content: null, error: e.message }); }
+ return;
+ }
+
+ // API: agent memory (parameterized)
+ if (pathname.match(/^\/dashboard\/api\/agents\/[^/]+\/memory$/)) {
+ const agentId = pathname.split("/")[4];
+ const agentsDir = "/home/openclaw/.openclaw/agents";
+ const date = url.searchParams.get("date") || new Date().toISOString().slice(0, 10);
+ const memPath = join(agentsDir, agentId, "memory", `${date}.md`);
+ try {
+ if (existsSync(memPath)) {
+ jsonResponse(res, { content: readFileSync(memPath, "utf-8") });
+ } else {
+ const fallback = join(agentsDir, agentId, "MEMORY.md");
+ if (existsSync(fallback)) {
+ const text = readFileSync(fallback, "utf-8");
+ jsonResponse(res, { content: text.slice(-2000) });
+ } else {
+ jsonResponse(res, { content: "" });
+ }
+ }
+ } catch (e: any) { jsonResponse(res, { content: "", error: e.message }); }
+ return;
+ }
+
+ // API: agent sessions (parameterized)
+ if (pathname.match(/^\/dashboard\/api\/agents\/[^/]+\/sessions$/)) {
+ const agentId = pathname.split("/")[4];
+ const agentsDir = "/home/openclaw/.openclaw/agents";
+ const sessionsFile = join(agentsDir, agentId, "sessions", "sessions.json");
+ try {
+ if (existsSync(sessionsFile)) {
+ const registry = JSON.parse(readFileSync(sessionsFile, "utf-8"));
+ const sessions = Object.entries(registry).map(([key, meta]: [string, any]) => ({
+ key, ...meta,
+ }));
+ sessions.sort((a: any, b: any) => (b.updatedAt || 0) - (a.updatedAt || 0));
+ jsonResponse(res, { sessions: sessions.slice(0, 50) });
+ } else {
+ jsonResponse(res, { sessions: [] });
+ }
+ } catch (e: any) {
+ jsonResponse(res, { error: e.message, sessions: [] });
+ }
+ return;
+ }
+
+ // Static file serving for /dashboard/*
+ let filePath: string;
+ if (pathname === "/dashboard" || pathname === "/dashboard/") {
+ filePath = join(STATIC_DIR, "index.html");
+ } else {
+ const relative = pathname.replace("/dashboard/", "");
+ if (relative.includes("..")) { res.writeHead(403); res.end("Forbidden"); return; }
+ filePath = join(STATIC_DIR, relative);
+ }
+
+ const resolved = resolve(filePath);
+ if (!resolved.startsWith(resolve(STATIC_DIR))) { res.writeHead(403); res.end("Forbidden"); return; }
+ if (!existsSync(resolved)) { res.writeHead(404); res.end("Not found"); return; }
+
+ const ext = extname(resolved);
+ res.setHeader("Content-Type", MIME[ext] || "application/octet-stream");
+ res.setHeader("Cache-Control", "no-cache");
+
+ const binaryExts = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".woff", ".woff2", ".ttf", ".eot"]);
+ if (binaryExts.has(ext)) {
+ res.end(readFileSync(resolved));
+ } else {
+ let content = readFileSync(resolved, "utf-8");
+ if (ext === ".html") {
+ content = content.replace(/\{\{AUTH_TOKEN\}\}/g, authToken);
+ }
+ res.end(content);
+ }
+ },
+ });
+
+ api.logger.info("Dashboard plugin registered at /dashboard");
+ },
+};
+
+export default plugin;
diff --git a/bates-core/plugins/dashboard/openclaw.plugin.json b/bates-core/plugins/dashboard/openclaw.plugin.json
new file mode 100644
index 0000000..e446404
--- /dev/null
+++ b/bates-core/plugins/dashboard/openclaw.plugin.json
@@ -0,0 +1,10 @@
+{
+ "id": "dashboard",
+ "name": "Command Center Dashboard",
+ "description": "Glassmorphism HUD dashboard for OpenClaw observability",
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ }
+}
diff --git a/bates-core/plugins/dashboard/package.json b/bates-core/plugins/dashboard/package.json
new file mode 100644
index 0000000..314a0b9
--- /dev/null
+++ b/bates-core/plugins/dashboard/package.json
@@ -0,0 +1,6 @@
+{
+ "devDependencies": {
+ "@types/node": "^25.2.3",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/bates-core/plugins/dashboard/static/assets/agent-avatar.png b/bates-core/plugins/dashboard/static/assets/agent-avatar.png
new file mode 100644
index 0000000..de341c6
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-avatar.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_Dark.png b/bates-core/plugins/dashboard/static/assets/agent-baby_Dark.png
new file mode 100644
index 0000000..743a04e
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_Dark.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_Ember.png b/bates-core/plugins/dashboard/static/assets/agent-baby_Ember.png
new file mode 100644
index 0000000..cd7d684
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_Ember.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_Sage.png b/bates-core/plugins/dashboard/static/assets/agent-baby_Sage.png
new file mode 100644
index 0000000..d76542c
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_Sage.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_aqua.png b/bates-core/plugins/dashboard/static/assets/agent-baby_aqua.png
new file mode 100644
index 0000000..516fdac
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_aqua.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_bolt.png b/bates-core/plugins/dashboard/static/assets/agent-baby_bolt.png
new file mode 100644
index 0000000..c742c0f
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_bolt.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_core.png b/bates-core/plugins/dashboard/static/assets/agent-baby_core.png
new file mode 100644
index 0000000..674fbea
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_core.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_frost.png b/bates-core/plugins/dashboard/static/assets/agent-baby_frost.png
new file mode 100644
index 0000000..a6b0297
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_frost.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_nova.png b/bates-core/plugins/dashboard/static/assets/agent-baby_nova.png
new file mode 100644
index 0000000..32f77dd
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_nova.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_pixel.png b/bates-core/plugins/dashboard/static/assets/agent-baby_pixel.png
new file mode 100644
index 0000000..8acbe99
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_pixel.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_sky.png b/bates-core/plugins/dashboard/static/assets/agent-baby_sky.png
new file mode 100644
index 0000000..261a8e6
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_sky.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/app-icon-small.png b/bates-core/plugins/dashboard/static/assets/app-icon-small.png
new file mode 100644
index 0000000..75648ad
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/app-icon-small.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/avatar-transparent.png b/bates-core/plugins/dashboard/static/assets/avatar-transparent.png
new file mode 100644
index 0000000..e408024
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/avatar-transparent.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/bg.jpg b/bates-core/plugins/dashboard/static/assets/bg.jpg
new file mode 100644
index 0000000..8396fb4
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/bg.jpg differ
diff --git a/bates-core/plugins/dashboard/static/assets/bg.png b/bates-core/plugins/dashboard/static/assets/bg.png
new file mode 100644
index 0000000..0a6424a
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/bg.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/bg2.png b/bates-core/plugins/dashboard/static/assets/bg2.png
new file mode 100644
index 0000000..5b09963
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/bg2.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/design-ref.png b/bates-core/plugins/dashboard/static/assets/design-ref.png
new file mode 100644
index 0000000..fadbfca
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/design-ref.png differ
diff --git a/bates-core/plugins/dashboard/static/assets/horizontal-logo.png b/bates-core/plugins/dashboard/static/assets/horizontal-logo.png
new file mode 100644
index 0000000..3a9be82
Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/horizontal-logo.png differ
diff --git a/bates-core/plugins/dashboard/static/index.html b/bates-core/plugins/dashboard/static/index.html
new file mode 100644
index 0000000..aa9fdd1
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/index.html
@@ -0,0 +1,268 @@
+
+
+
+
+
+ Bates Command Center
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
—Agents
+
—Unread
+
—Tasks
+
—Next Cron
+
+
+
+ 🔍
+
+
+
+ Projects
+
+
+
+
+
+
Indexation Status
+
+
+
+
+
+ Email accounts (4)
+ rk@vernot, rk@fdesk, cp-desk, hello@fdesk
+ Last sync: Feb 11
+
+
+ Search index
+ Phase 4/5 complete
+ Monitor: Active
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Delegations
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bates-core/plugins/dashboard/static/js/app.js b/bates-core/plugins/dashboard/static/js/app.js
new file mode 100644
index 0000000..bffd2ce
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/app.js
@@ -0,0 +1,522 @@
+/**
+ * Bates Command Center — App Controller v4
+ * 5 tabs · persistent chat drawer · glassmorphism
+ */
+(function () {
+ const panels = {};
+ let gateway = null;
+ let currentView = 'overview';
+
+ const VIEW_PANELS = {
+ overview: ['ceo', 'tasks', 'status', 'agents', 'files', 'crons', 'community'],
+ agents: ['agents'],
+ operations: ['crons', 'delegations', 'integrations', 'costs', 'settings'],
+ standup: ['standup'],
+ memory: ['memory'],
+ };
+
+ const DASH_API_BASE = '';
+
+ window.Dashboard = {
+ DASH_API: DASH_API_BASE,
+ registerPanel(id, mod) { panels[id] = mod; },
+ getGateway() { return gateway; },
+
+ async fetchApi(ep) {
+ try {
+ const headers = {};
+ const token = window.__GATEWAY_CONFIG?.token;
+ if (token) headers['Authorization'] = 'Bearer ' + token;
+ return await (await fetch(`/dashboard/api/${ep}`, { headers })).json();
+ }
+ catch (e) { console.error(`API ${ep}:`, e); return null; }
+ },
+
+ // Compact task row for project detail modals (spreadsheet-dense)
+ renderTaskRowCompact(t) {
+ const done = t.completed;
+ const overdue = !done && t.dueDate && t.dueDate < new Date().toISOString().slice(0, 10);
+ const PRI_COLORS = { urgent: '#ff4757', important: '#ffa502', medium: '#00d4ff', low: '#747d8c' };
+ const taskUrl = t.source === 'To Do'
+ ? `https://to-do.office.com/tasks/id/${t.id}/details`
+ : `https://tasks.office.com/vernot.com/Home/Task/${t.id}`;
+ return `
+ |
+ |
+ ${Dashboard.esc(t.title || '—')} |
+ ${t.dueDate || ''} |
+
`;
+ },
+
+ // Shared task row renderer used by panel-tasks.js and project detail modals
+ renderTaskRow(t, opts) {
+ opts = opts || {};
+ const done = t.completed;
+ const overdue = !done && t.dueDate && t.dueDate < new Date().toISOString().slice(0, 10);
+ const PRI_COLORS = { urgent: '#ff4757', important: '#ffa502', medium: '#00d4ff', low: '#747d8c' };
+ const taskUrl = t.source === 'To Do'
+ ? `https://to-do.office.com/tasks/id/${t.id}/details`
+ : `https://tasks.office.com/vernot.com/Home/Task/${t.id}`;
+ return `
+
+
+
+
${Dashboard.esc(t.title || '—')}
+
+ ${t.dueDate ? '📅 ' + t.dueDate : ''}
+ ${Dashboard.esc(t.planName || '')}
+ ${Dashboard.esc(t.source || '')}
+ ${t.checklistTotal ? `☑ ${t.checklistDone}/${t.checklistTotal}` : ''}
+ ${t.percentComplete > 0 && t.percentComplete < 100 ? `${t.percentComplete}%` : ''}
+
+
+
`;
+ },
+
+ // Wire click and complete handlers on task rows within a container
+ wireTaskRows(container, onComplete) {
+ if (!container) return;
+ container.querySelectorAll('.task-row-clickable').forEach(el => {
+ el.style.cursor = 'pointer';
+ el.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const url = el.dataset.url;
+ if (url) window.open(url, '_blank');
+ });
+ });
+ container.querySelectorAll('.task-complete-btn').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const row = btn.closest('.task-row-shared');
+ if (!row || row.classList.contains('done')) return;
+ btn.disabled = true;
+ btn.textContent = '⏳';
+ try {
+ const headers = { 'Content-Type': 'application/json' };
+ const token = window.__GATEWAY_CONFIG?.token;
+ if (token) headers['Authorization'] = 'Bearer ' + token;
+ const resp = await fetch('/dashboard/api/tasks/complete', {
+ method: 'POST', headers,
+ body: JSON.stringify({ taskId: row.dataset.taskId, source: row.dataset.source, project: row.dataset.project })
+ });
+ const result = await resp.json();
+ if (result.success) {
+ row.classList.add('done');
+ btn.textContent = '✓';
+ btn.style.background = 'var(--green)';
+ btn.style.borderColor = 'var(--green)';
+ btn.style.color = '#fff';
+ if (onComplete) onComplete();
+ } else {
+ btn.textContent = '✗';
+ btn.style.color = 'var(--red)';
+ setTimeout(() => { btn.textContent = '✓'; btn.style.color = ''; btn.disabled = false; }, 2000);
+ }
+ } catch {
+ btn.textContent = '✗';
+ setTimeout(() => { btn.textContent = '✓'; btn.disabled = false; }, 2000);
+ }
+ });
+ });
+ },
+
+ timeAgo(d) {
+ if (!d) return 'never';
+ const ms = Date.now() - new Date(d).getTime();
+ if (ms < 0) { const a = -ms; return a < 60e3 ? `in ${(a/1e3)|0}s` : a < 36e5 ? `in ${(a/6e4)|0}m` : a < 864e5 ? `in ${(a/36e5)|0}h` : `in ${(a/864e5)|0}d`; }
+ return ms < 60e3 ? `${(ms/1e3)|0}s ago` : ms < 36e5 ? `${(ms/6e4)|0}m ago` : ms < 864e5 ? `${(ms/36e5)|0}h ago` : `${(ms/864e5)|0}d ago`;
+ },
+ formatSize(b) { return b < 1024 ? b+'B' : b < 1048576 ? (b/1024).toFixed(1)+'KB' : (b/1048576).toFixed(1)+'MB'; },
+ esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; },
+ };
+
+ // ─── Navigation ───
+ function switchView(id) {
+ if (!VIEW_PANELS[id]) return;
+ currentView = id;
+ document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
+ document.getElementById('view-' + id)?.classList.add('active');
+ document.querySelectorAll('.nav-tab').forEach(n => n.classList.remove('active'));
+ document.querySelectorAll(`.nav-tab[data-view="${id}"]`).forEach(n => n.classList.add('active'));
+ for (const pid of VIEW_PANELS[id]) {
+ try { panels[pid]?.refresh?.(gateway); } catch (e) { console.error(`Refresh ${pid}:`, e); }
+ }
+ }
+
+ // ─── Operations Sub-Nav ───
+ function setupOpsNav() {
+ const nav = document.getElementById('ops-nav');
+ if (!nav) return;
+ nav.addEventListener('click', (e) => {
+ const btn = e.target.closest('.ops-nav-btn');
+ if (!btn) return;
+ const sectionId = btn.dataset.section;
+ if (!sectionId) return;
+ // Update active state
+ nav.querySelectorAll('.ops-nav-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ // Scroll to section and expand it
+ const section = document.getElementById(sectionId);
+ if (section) {
+ section.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ const box = section.querySelector('.ops-box');
+ if (box) box.classList.remove('collapsed');
+ }
+ });
+ }
+
+ // ─── Chat Drawer ───
+ function setupChatDrawer() {
+ const drawer = document.getElementById('chat-drawer');
+ const toggle = document.getElementById('chat-toggle-btn');
+ const close = document.getElementById('chat-drawer-close');
+ if (!drawer || !toggle) return;
+
+ function setOpen(open) {
+ drawer.classList.toggle('open', open);
+ toggle.classList.toggle('active', open);
+ localStorage.setItem('bates-chat-open', open ? '1' : '0');
+ }
+ toggle.addEventListener('click', () => setOpen(!drawer.classList.contains('open')));
+ close?.addEventListener('click', () => setOpen(false));
+
+ const saved = localStorage.getItem('bates-chat-open');
+ setOpen(saved !== '0');
+ }
+
+ // ─── Clock ───
+ function updateClock() {
+ const el = document.getElementById('clock');
+ if (!el) return;
+ el.textContent = new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/Lisbon', hour: '2-digit', minute: '2-digit' });
+ }
+
+ // ─── Connection ───
+ function updateConn(status) {
+ const dot = document.getElementById('conn-dot');
+ const lbl = document.getElementById('conn-label');
+ if (dot) dot.className = 'conn-dot ' + status;
+ if (lbl) lbl.textContent = status === 'connected' ? 'LIVE' : status.toUpperCase();
+ }
+
+ // ─── Refresh buttons ───
+ function setupRefresh() {
+ document.querySelectorAll('.panel-refresh').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const pid = (btn.dataset.action || '').replace('refresh-', '');
+ try { panels[pid]?.refresh?.(gateway); } catch {}
+ });
+ });
+ }
+
+ // ─── Overview metrics ───
+ window._updateOverviewMetrics = function(d) {
+ if (!d) return;
+ const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
+ if (d.activeAgents !== undefined) set('metric-agents-val', d.activeAgents);
+ if (d.emails !== undefined) set('metric-emails-val', d.emails);
+ if (d.tasks !== undefined) set('metric-tasks-val', d.tasks);
+ if (d.nextCron !== undefined) set('metric-cron-val', d.nextCron);
+ };
+
+ // ─── Agents summary hook ───
+ const _origReg = window.Dashboard.registerPanel;
+ window.Dashboard.registerPanel = function(id, mod) {
+ if (id === 'agents') {
+ const oRefresh = mod.refresh, oInit = mod.init;
+ mod.refresh = async gw => { await oRefresh(gw); updateAgentsSummary(); };
+ mod.init = async gw => { await oInit(gw); updateAgentsSummary(); };
+ }
+ _origReg(id, mod);
+ };
+
+ function updateAgentsSummary() {
+ const el = document.getElementById('panel-agents-summary');
+ if (!el) return;
+ const cards = document.querySelectorAll('#panel-agents .acard, #panel-agents .agent-card');
+ if (!cards.length) { el.innerHTML = 'No agents online
'; return; }
+ let html = '';
+ let n = 0;
+ cards.forEach(c => {
+ if (n >= 6) return;
+ const name = c.querySelector('.aname, .agent-name');
+ const role = c.querySelector('.arole, .agent-role');
+ const dot = c.querySelector('.status-dot');
+ if (!name) return;
+ html += `
+
+ ${name.textContent}
+ ${role ? `${role.textContent}` : ''}
+
`;
+ n++;
+ });
+ if (cards.length > 6) html += `
View all ${cards.length} →
`;
+ html += '
';
+ el.innerHTML = html;
+ }
+
+ // ─── Rollout panel (standalone, not injected into project card) ───
+
+ // ─── Init ───
+ async function init() {
+ updateClock();
+ setInterval(updateClock, 1000);
+
+ document.querySelectorAll('.nav-tab').forEach(b => b.addEventListener('click', () => switchView(b.dataset.view)));
+ setupChatDrawer();
+ setupOpsNav();
+ setupRefresh();
+
+ const ov = document.getElementById('soul-modal-overlay');
+ const cl = document.getElementById('soul-modal-close');
+ if (ov) ov.addEventListener('click', e => { if (e.target === ov) ov.classList.remove('visible'); });
+ if (cl) cl.addEventListener('click', () => ov.classList.remove('visible'));
+
+ const config = window.__GATEWAY_CONFIG || {};
+ gateway = new GatewayClient(config);
+ gateway.onStatusChange = updateConn;
+ updateConn('reconnecting');
+
+ for (const [id, p] of Object.entries(panels)) {
+ try { await p.init?.(gateway); } catch (e) { console.error(`Init ${id}:`, e); }
+ }
+
+ gateway.connect().then(() => {
+ for (const pid of VIEW_PANELS[currentView]) {
+ try { panels[pid]?.refresh?.(gateway); } catch {}
+ }
+ // Refresh chat panel after auth is confirmed
+ if (panels.chat?.refresh) try { panels.chat.refresh(gateway); } catch {}
+ }).catch(e => { console.error('WS failed:', e); updateConn('disconnected'); });
+
+ // Load projects from API and render
+ await loadProjects();
+
+ setInterval(() => {
+ for (const pid of VIEW_PANELS[currentView]) {
+ try { panels[pid]?.refresh?.(gateway); } catch {}
+ }
+ }, 30000);
+ }
+
+ // ─── Project Data (loaded from API) ───
+ let PROJECT_DATA = {};
+ window.PROJECT_DATA = PROJECT_DATA;
+
+ async function loadProjects() {
+ try {
+ const data = await Dashboard.fetchApi('projects');
+ if (data?.projects) {
+ PROJECT_DATA = {};
+ for (const p of data.projects) PROJECT_DATA[p.id] = p;
+ window.PROJECT_DATA = PROJECT_DATA;
+ renderProjectBoxes();
+ }
+ } catch (e) { console.error('Load projects:', e); }
+ }
+
+ function renderProjectBoxes() {
+ const row = document.getElementById('projects-row');
+ if (!row) return;
+ const projects = Object.values(PROJECT_DATA);
+ let h = '';
+ for (const p of projects) {
+ h += `
+
+
Deputy: ${Dashboard.esc(p.agentName || p.agent || 'None')}
+
+
`;
+ }
+ h += ``;
+ row.innerHTML = h;
+ setupProjectBoxes();
+ }
+
+ function setupProjectBoxes() {
+ document.querySelectorAll('.project-box').forEach(box => {
+ if (box.id === 'add-project-btn') {
+ box.addEventListener('click', () => openProjectEditor());
+ return;
+ }
+ const pid = box.dataset.project;
+ if (!pid || !PROJECT_DATA[pid]) return;
+ box.addEventListener('click', (e) => {
+ e.stopPropagation();
+ openProjectDetail(pid);
+ });
+ });
+ }
+
+ function openProjectEditor(existing) {
+ const ov = document.getElementById('soul-modal-overlay'); if (!ov) return;
+ const titleEl = document.getElementById('soul-modal-title');
+ const bodyEl = document.getElementById('soul-modal-body');
+ const isEdit = !!existing;
+ titleEl.textContent = isEdit ? 'Edit Project' : 'New Project';
+
+ bodyEl.innerHTML = `
+ `;
+ ov.classList.add('visible');
+
+ document.getElementById('pf-cancel').addEventListener('click', () => ov.classList.remove('visible'));
+
+ document.getElementById('pf-save').addEventListener('click', async () => {
+ const project = {
+ id: document.getElementById('pf-id').value.trim().toLowerCase().replace(/[^a-z0-9-]/g, ''),
+ name: document.getElementById('pf-name').value.trim(),
+ icon: document.getElementById('pf-icon').value.trim() || '📁',
+ desc: document.getElementById('pf-desc').value.trim(),
+ agent: document.getElementById('pf-agent').value.trim(),
+ agentName: document.getElementById('pf-agentname').value.trim(),
+ accent: document.getElementById('pf-accent').value,
+ planUrl: document.getElementById('pf-planurl').value.trim(),
+ };
+ if (!project.id || !project.name) { document.getElementById('pf-msg').textContent = 'ID and Name are required'; return; }
+ const endpoint = isEdit ? 'projects/update' : 'projects';
+ try {
+ const token = window.__GATEWAY_CONFIG?.token;
+ const headers = { 'Content-Type': 'application/json' };
+ if (token) headers['Authorization'] = 'Bearer ' + token;
+ const resp = await fetch('/dashboard/api/' + endpoint, {
+ method: 'POST', headers, body: JSON.stringify(project)
+ });
+ const result = await resp.json();
+ if (result.success || result.project) {
+ ov.classList.remove('visible');
+ await loadProjects();
+ } else {
+ document.getElementById('pf-msg').textContent = result.error || 'Failed';
+ }
+ } catch (e) { document.getElementById('pf-msg').textContent = 'Error: ' + e.message; }
+ });
+
+ if (isEdit) {
+ document.getElementById('pf-delete').addEventListener('click', async () => {
+ if (!confirm('Delete project "' + existing.name + '"? This only removes it from the dashboard.')) return;
+ try {
+ const token = window.__GATEWAY_CONFIG?.token;
+ const headers = { 'Content-Type': 'application/json' };
+ if (token) headers['Authorization'] = 'Bearer ' + token;
+ await fetch('/dashboard/api/projects/delete', {
+ method: 'POST', headers, body: JSON.stringify({ id: existing.id })
+ });
+ ov.classList.remove('visible');
+ await loadProjects();
+ } catch {}
+ });
+ }
+ }
+
+ function openProjectDetail(pid) {
+ const p = PROJECT_DATA[pid];
+ if (!p) return;
+ const ov = document.getElementById('soul-modal-overlay');
+ if (!ov) return;
+ const titleEl = document.getElementById('soul-modal-title');
+ const bodyEl = document.getElementById('soul-modal-body');
+ titleEl.textContent = p.icon + ' ' + p.name;
+ bodyEl.innerHTML = `
+
+
+
${Dashboard.esc(p.desc)}
+
+
+
+
+
Planner Tasks
+
📋 Loading…
+
+
+
Recent Files
+
📁 Loading...
+
+
`;
+ ov.classList.add('visible');
+
+ // Load project tasks using shared task row component
+ (function loadProjectTasks() {
+ const tel = document.getElementById('project-detail-tasks-' + pid);
+ if (!tel) return;
+
+ function renderProjectTaskRows(tasks) {
+ const incomplete = tasks.filter(t => !t.completed && !t.error);
+ const done = tasks.filter(t => t.completed);
+ if (!incomplete.length && !done.length) { tel.textContent = '📋 No tasks'; return; }
+ let h = '';
+ for (const t of incomplete.slice(0, 20)) h += Dashboard.renderTaskRowCompact(t);
+ h += '
';
+ if (done.length) h += `✓ ${done.length} completed
`;
+ if (incomplete.length > 20) { const planLink = PROJECT_DATA[pid]?.planUrl; h += `+ ${incomplete.length - 20} more → Open in Planner`; }
+ tel.innerHTML = h;
+ Dashboard.wireTaskRows(tel);
+ }
+
+ const pt = window._getProjectTasks?.(pid);
+ if (pt && pt.tasks?.length) {
+ renderProjectTaskRows(pt.tasks);
+ } else if (pt && pt.tasks?.length === 0) {
+ tel.textContent = '📋 No tasks in this plan';
+ } else {
+ Dashboard.fetchApi('tasks').then(data => {
+ if (data?.byProject?.[pid]?.tasks) {
+ renderProjectTaskRows(data.byProject[pid].tasks);
+ } else {
+ tel.textContent = '📋 No plan configured';
+ }
+ }).catch(() => { tel.textContent = '📋 Could not load tasks'; });
+ }
+ })();
+
+ // Try to load filtered files
+ Dashboard.fetchApi('files').then(files => {
+ const el = document.getElementById('project-detail-files-' + pid);
+ if (!el) return;
+ const all = Array.isArray(files) ? files : [];
+ const kw = pid === 'synapse' ? 'synapse' : pid === 'escola' ? 'escola' : pid === 'fdesk' ? 'fdesk' : pid;
+ const filtered = all.filter(f => (f.path || '').toLowerCase().includes(kw)).slice(0, 5);
+ if (!filtered.length) { el.textContent = '📁 No recent files for this project'; return; }
+ el.innerHTML = filtered.map(f => `${Dashboard.esc(f.name)} ${Dashboard.timeAgo(f.modified)}
`).join('');
+ }).catch(() => {
+ const el = document.getElementById('project-detail-files-' + pid);
+ if (el) el.textContent = '📁 Could not load files';
+ });
+ }
+
+ window._openProjectEditor = openProjectEditor;
+
+ document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();
+})();
diff --git a/bates-core/plugins/dashboard/static/js/gateway.js b/bates-core/plugins/dashboard/static/js/gateway.js
new file mode 100644
index 0000000..b439cb2
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/gateway.js
@@ -0,0 +1,685 @@
+/**
+ * OpenClaw Gateway WebSocket Client
+ * Protocol v3 — typed frames { type: "req"|"res"|"event" }
+ * Includes Ed25519 device auth for operator scopes.
+ */
+
+// ─── Ed25519 (minimal, browser-only via noble-ed25519-style inline) ───
+// We use SubtleCrypto SHA-512 + a tiny Ed25519 sign implementation.
+// For brevity we import the same device-identity approach as Control UI:
+// generate keypair, store in localStorage, sign connect payload.
+
+const DEVICE_STORAGE_KEY = "openclaw-device-identity-v1";
+const DEVICE_AUTH_TOKEN_KEY = "openclaw.device.auth.v1";
+
+// ─── Helpers ───
+function b64url(bytes) {
+ let s = "";
+ for (const b of bytes) s += String.fromCharCode(b);
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
+}
+function b64urlDecode(str) {
+ const s = str.replace(/-/g, "+").replace(/_/g, "/");
+ const padded = s + "=".repeat((4 - s.length % 4) % 4);
+ const bin = atob(padded);
+ const bytes = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
+ return bytes;
+}
+function hexFromBytes(bytes) {
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
+}
+async function sha256Hex(bytes) {
+ const hash = await crypto.subtle.digest("SHA-256", bytes.buffer);
+ return hexFromBytes(new Uint8Array(hash));
+}
+
+// ─── Ed25519 via noble-ed25519 approach (reuse Control UI's stored keys) ───
+// We need to sign payloads. The Control UI stores keys as base64url-encoded
+// Ed25519 seed (private) and public key. We'll use the Web Crypto Ed25519 API
+// if available (Chrome 113+, Firefox 128+), or fall back to importing the
+// existing noble-ed25519 implementation pattern.
+
+// Try native Ed25519 first (available in modern browsers)
+async function ed25519Sign(privateKeyBytes, message) {
+ // Try native Web Crypto Ed25519
+ try {
+ const key = await crypto.subtle.importKey(
+ "pkcs8",
+ ed25519SeedToPkcs8(privateKeyBytes),
+ { name: "Ed25519" },
+ false,
+ ["sign"]
+ );
+ const sig = await crypto.subtle.sign("Ed25519", key, new TextEncoder().encode(message));
+ return new Uint8Array(sig);
+ } catch (e) {
+ // Native Ed25519 not available, fall back to noble implementation
+ return ed25519SignNoble(privateKeyBytes, new TextEncoder().encode(message));
+ }
+}
+
+// Convert 32-byte Ed25519 seed to PKCS#8 format for Web Crypto
+function ed25519SeedToPkcs8(seed) {
+ // PKCS#8 wrapper for Ed25519 private key (seed)
+ const prefix = new Uint8Array([
+ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70,
+ 0x04, 0x22, 0x04, 0x20
+ ]);
+ const result = new Uint8Array(prefix.length + seed.length);
+ result.set(prefix);
+ result.set(seed, prefix.length);
+ return result.buffer;
+}
+
+// Minimal noble-ed25519 sign (synchronous-style using SHA-512 from SubtleCrypto)
+async function sha512(data) {
+ const hash = await crypto.subtle.digest("SHA-512", data instanceof Uint8Array ? data.buffer : data);
+ return new Uint8Array(hash);
+}
+
+// We'll use a simplified approach: if native Ed25519 fails, we load the
+// noble-ed25519 micro library dynamically. For now, store a minimal implementation.
+// This is the same Ed25519 implementation used by Control UI (inlined).
+
+// ─── Modular arithmetic for Ed25519 ───
+const P = 2n ** 255n - 19n;
+const N = 2n ** 252n + 27742317777372353535851937790883648493n;
+const Gx = 15112221349535807912866137220509078750507884956996801397894129974371384098553n;
+const Gy = 46316835694926478169428394003475163141307993866256225615783033890098355573289n;
+const D_CONST = 37095705934669439343138083508754565189542113879843219016388785533085940283555n;
+
+function mod(a, m = P) { let r = a % m; return r >= 0n ? r : m + r; }
+function modInv(a, m = P) {
+ let [old_r, r] = [mod(a, m), m];
+ let [old_s, s] = [1n, 0n];
+ while (r !== 0n) {
+ const q = old_r / r;
+ [old_r, r] = [r, old_r - q * r];
+ [old_s, s] = [s, old_s - q * s];
+ }
+ return mod(old_s, m);
+}
+function modN(a) { return mod(a, N); }
+
+class EdPoint {
+ constructor(X, Y, Z, T) { this.X = X; this.Y = Y; this.Z = Z; this.T = T; }
+ static ZERO = new EdPoint(0n, 1n, 1n, 0n);
+ static BASE = new EdPoint(Gx, Gy, 1n, mod(Gx * Gy));
+
+ add(other) {
+ const a = -1n; // Ed25519 a = -1
+ const { X: X1, Y: Y1, Z: Z1, T: T1 } = this;
+ const { X: X2, Y: Y2, Z: Z2, T: T2 } = other;
+ const A = mod(X1 * X2);
+ const B = mod(Y1 * Y2);
+ const C = mod(T1 * D_CONST * T2);
+ const DD = mod(Z1 * Z2);
+ const E = mod((X1 + Y1) * (X2 + Y2) - A - B);
+ const F = mod(DD - C);
+ const G = mod(DD + C);
+ const H = mod(B - a * A);
+ return new EdPoint(mod(E * F), mod(G * H), mod(F * G), mod(E * H));
+ }
+
+ double() {
+ const a = -1n;
+ const { X, Y, Z } = this;
+ const A = mod(X * X);
+ const B = mod(Y * Y);
+ const C = mod(2n * mod(Z * Z));
+ const D2 = mod(a * A);
+ const E = mod(mod((X + Y) * (X + Y)) - A - B);
+ const G = mod(D2 + B);
+ const F = mod(G - C);
+ const H = mod(D2 - B);
+ return new EdPoint(mod(E * F), mod(G * H), mod(F * G), mod(E * H));
+ }
+
+ multiply(scalar) {
+ let result = EdPoint.ZERO;
+ let base = this;
+ let s = scalar;
+ while (s > 0n) {
+ if (s & 1n) result = result.add(base);
+ base = base.double();
+ s >>= 1n;
+ }
+ return result;
+ }
+
+ toAffine() {
+ const inv = modInv(this.Z);
+ return { x: mod(this.X * inv), y: mod(this.Y * inv) };
+ }
+
+ toBytes() {
+ const { x, y } = this.toAffine();
+ const bytes = numberToLEBytes(y, 32);
+ if (x & 1n) bytes[31] |= 0x80;
+ return bytes;
+ }
+}
+
+function numberToLEBytes(n, len) {
+ const bytes = new Uint8Array(len);
+ let v = n;
+ for (let i = 0; i < len; i++) { bytes[i] = Number(v & 0xffn); v >>= 8n; }
+ return bytes;
+}
+function bytesToNumberLE(bytes) {
+ let n = 0n;
+ for (let i = bytes.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(bytes[i]);
+ return n;
+}
+
+async function ed25519SignNoble(seed, message) {
+ // Hash seed to get (scalar, prefix)
+ const h = await sha512(seed);
+ const scalar_bytes = h.slice(0, 32);
+ scalar_bytes[0] &= 248;
+ scalar_bytes[31] &= 127;
+ scalar_bytes[31] |= 64;
+ const scalar = bytesToNumberLE(scalar_bytes);
+ const prefix = h.slice(32, 64);
+
+ // Public key
+ const pubPoint = EdPoint.BASE.multiply(scalar);
+ const pubBytes = pubPoint.toBytes();
+
+ // r = SHA-512(prefix || message) mod N
+ const rHash = await sha512(concat(prefix, message));
+ const r = modN(bytesToNumberLE(rHash));
+
+ // R = r * G
+ const R = EdPoint.BASE.multiply(r);
+ const RBytes = R.toBytes();
+
+ // S = (r + SHA-512(R || pubKey || message) * scalar) mod N
+ const kHash = await sha512(concat(RBytes, pubBytes, message));
+ const k = modN(bytesToNumberLE(kHash));
+ const S = modN(r + k * scalar);
+ const SBytes = numberToLEBytes(S, 32);
+
+ // Signature = R || S
+ return concat(RBytes, SBytes);
+}
+
+function concat(...arrays) {
+ const len = arrays.reduce((s, a) => s + a.length, 0);
+ const result = new Uint8Array(len);
+ let offset = 0;
+ for (const a of arrays) { result.set(a, offset); offset += a.length; }
+ return result;
+}
+
+// ─── Device Identity Management ───
+async function getOrCreateDeviceIdentity() {
+ if (!crypto.subtle) return null;
+ try {
+ const stored = localStorage.getItem(DEVICE_STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ if (parsed?.version === 1 && parsed.deviceId && parsed.publicKey && parsed.privateKey) {
+ // Verify deviceId matches publicKey
+ const computedId = await sha256Hex(b64urlDecode(parsed.publicKey));
+ if (computedId !== parsed.deviceId) {
+ parsed.deviceId = computedId;
+ localStorage.setItem(DEVICE_STORAGE_KEY, JSON.stringify(parsed));
+ }
+ return { deviceId: parsed.deviceId, publicKey: parsed.publicKey, privateKey: parsed.privateKey };
+ }
+ }
+ } catch {}
+
+ // Generate new keypair using our Ed25519 implementation
+ const seed = crypto.getRandomValues(new Uint8Array(32));
+ const h = await sha512(seed);
+ const scalar_bytes = h.slice(0, 32);
+ scalar_bytes[0] &= 248;
+ scalar_bytes[31] &= 127;
+ scalar_bytes[31] |= 64;
+ const scalar = bytesToNumberLE(scalar_bytes);
+ const pubPoint = EdPoint.BASE.multiply(scalar);
+ const pubBytes = pubPoint.toBytes();
+ const deviceId = await sha256Hex(pubBytes);
+
+ const identity = {
+ version: 1,
+ deviceId,
+ publicKey: b64url(pubBytes),
+ privateKey: b64url(seed),
+ createdAtMs: Date.now()
+ };
+ localStorage.setItem(DEVICE_STORAGE_KEY, JSON.stringify(identity));
+ return { deviceId, publicKey: identity.publicKey, privateKey: identity.privateKey };
+}
+
+function getStoredDeviceToken(deviceId, role) {
+ try {
+ const stored = localStorage.getItem(DEVICE_AUTH_TOKEN_KEY);
+ if (!stored) return null;
+ const parsed = JSON.parse(stored);
+ if (!parsed || parsed.version !== 1 || parsed.deviceId !== deviceId) return null;
+ const entry = parsed.tokens[role.trim()];
+ return entry?.token || null;
+ } catch { return null; }
+}
+
+function storeDeviceToken(deviceId, role, token, scopes) {
+ const key = role.trim();
+ let data = { version: 1, deviceId, tokens: {} };
+ try {
+ const existing = JSON.parse(localStorage.getItem(DEVICE_AUTH_TOKEN_KEY));
+ if (existing?.version === 1 && existing.deviceId === deviceId) {
+ data.tokens = { ...existing.tokens };
+ }
+ } catch {}
+ data.tokens[key] = { token, role: key, scopes, updatedAtMs: Date.now() };
+ localStorage.setItem(DEVICE_AUTH_TOKEN_KEY, JSON.stringify(data));
+}
+
+function clearDeviceToken(deviceId, role) {
+ try {
+ const existing = JSON.parse(localStorage.getItem(DEVICE_AUTH_TOKEN_KEY));
+ if (!existing || existing.version !== 1 || existing.deviceId !== deviceId) return;
+ const tokens = { ...existing.tokens };
+ delete tokens[role.trim()];
+ localStorage.setItem(DEVICE_AUTH_TOKEN_KEY, JSON.stringify({ ...existing, tokens }));
+ } catch {}
+}
+
+function buildDeviceAuthPayload(opts) {
+ const version = opts.version || (opts.nonce ? "v2" : "v1");
+ const scopeStr = (opts.scopes || []).join(",");
+ const tokenStr = opts.token || "";
+ const parts = [version, opts.deviceId, opts.clientId, opts.clientMode, opts.role, scopeStr, String(opts.signedAtMs), tokenStr];
+ if (version === "v2" && opts.nonce) parts.push(opts.nonce);
+ return parts.join("|");
+}
+
+async function signPayload(privateKeyB64, payload) {
+ const seed = b64urlDecode(privateKeyB64);
+ const msg = new TextEncoder().encode(payload);
+ // Try native Web Crypto Ed25519 first (Chrome 113+, Firefox 128+)
+ try {
+ const pkcs8 = ed25519SeedToPkcs8(seed);
+ const key = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, false, ["sign"]);
+ const sig = await crypto.subtle.sign("Ed25519", key, msg);
+ return b64url(new Uint8Array(sig));
+ } catch {
+ // Fall back to noble implementation
+ const sig = await ed25519SignNoble(seed, msg);
+ return b64url(sig);
+ }
+}
+
+function generateUUID() {
+ if (crypto.randomUUID) return crypto.randomUUID();
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+ const hex = hexFromBytes(bytes);
+ return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
+}
+
+// ─── Gateway Client ───
+class GatewayClient {
+ constructor(config) {
+ this.wsUrl = config.wsUrl;
+ this.token = config.token;
+ this.ws = null;
+ this.connected = false;
+ this.authenticated = false;
+ this.pendingRpc = new Map();
+ this.subscribers = new Map();
+ this.rpcIdCounter = 0;
+ this.reconnectDelay = 2000;
+ this.maxReconnectDelay = 30000;
+ this.onStatusChange = null;
+ this._shouldReconnect = true;
+ this._connectResolve = null;
+ this._connectReject = null;
+ this.serverInfo = null;
+ this.features = null;
+ this._connectNonce = null;
+ this._connectSent = false;
+ this._authFailed = false;
+ this._retryCount = 0;
+ this._maxRetries = 5;
+ this._retryDelays = [2000, 4000, 8000, 16000, 30000];
+ this.lastError = null;
+ }
+
+ connect() {
+ return new Promise((resolve, reject) => {
+ this._setStatus("reconnecting");
+ this._connectResolve = resolve;
+ this._connectReject = reject;
+ this._connectNonce = null;
+ this._connectSent = false;
+
+ try {
+ this.ws = new WebSocket(this.wsUrl);
+ } catch (e) {
+ this._setStatus("disconnected");
+ this._connectResolve = null;
+ this._connectReject = null;
+ reject(e);
+ return;
+ }
+
+ this.ws.onopen = () => {
+ console.log("[GW] WebSocket open");
+ this.connected = true;
+ this._authFailed = false;
+ // If server doesn't send a challenge within 2s, send connect request anyway
+ this._challengeTimer = setTimeout(() => {
+ if (!this._connectSent && this.connected) {
+ console.log("[GW] No challenge received, sending connect without nonce");
+ this._sendConnectRequest(null);
+ }
+ }, 2000);
+ };
+
+ this.ws.onmessage = (event) => {
+ let msg;
+ try { msg = JSON.parse(event.data); } catch { return; }
+ this._handleMessage(msg);
+ };
+
+ this.ws.onerror = () => {
+ if (!this.authenticated && this._connectReject) {
+ const rej = this._connectReject;
+ this._connectResolve = null;
+ this._connectReject = null;
+ rej(new Error("WebSocket error"));
+ }
+ };
+
+ this.ws.onclose = (ev) => {
+ this.connected = false;
+ const wasAuthenticated = this.authenticated;
+ this.authenticated = false;
+
+ if (this._challengeTimer) { clearTimeout(this._challengeTimer); this._challengeTimer = null; }
+
+ for (const [, { reject: rej }] of this.pendingRpc) {
+ rej(new Error("Connection closed"));
+ }
+ this.pendingRpc.clear();
+
+ if (this._connectReject) {
+ const rej = this._connectReject;
+ this._connectResolve = null;
+ this._connectReject = null;
+ rej(new Error("Connection closed before auth"));
+ }
+
+ // Don't reconnect on explicit auth rejection
+ const noReconnectCodes = [4001, 4003, 4008, 4009];
+ if (noReconnectCodes.includes(ev.code) || this._authFailed) {
+ console.warn(`[GW] Close code=${ev.code}, auth failed — NOT reconnecting`);
+ this._shouldReconnect = false;
+ this._setStatus("auth_failed");
+ return;
+ }
+
+ // Cap retries at _maxRetries
+ if (!wasAuthenticated) {
+ this._retryCount++;
+ if (this._retryCount >= this._maxRetries) {
+ console.warn(`[GW] Max retries (${this._maxRetries}) reached, stopping`);
+ this._shouldReconnect = false;
+ this._setStatus("max_retries");
+ return;
+ }
+ } else {
+ // Successful connection was lost — reset retry count
+ this._retryCount = 0;
+ }
+
+ this._setStatus("disconnected");
+
+ if (this._shouldReconnect) {
+ const delay = wasAuthenticated ? 2000 : (this._retryDelays[this._retryCount - 1] || 30000);
+ console.log(`[GW] Reconnecting in ${delay}ms (attempt ${this._retryCount}/${this._maxRetries}, code=${ev.code})`);
+ setTimeout(() => this._reconnect(), delay);
+ } else {
+ console.log(`[GW] Not reconnecting (code=${ev.code})`);
+ this._setStatus("disconnected");
+ }
+ };
+ });
+ }
+
+ async _handleMessage(msg) {
+ // Step 1: Challenge — build and send connect request with device auth
+ if (msg.type === "event" && msg.event === "connect.challenge") {
+ if (this._challengeTimer) { clearTimeout(this._challengeTimer); this._challengeTimer = null; }
+ const nonce = msg.payload?.nonce || null;
+ this._connectNonce = nonce;
+ await this._sendConnectRequest(nonce);
+ return;
+ }
+
+ // Step 2: Connect response
+ if (msg.type === "res" && msg.id === "connect") {
+ if (msg.ok) {
+ console.log("[GW] Authenticated successfully");
+ this.authenticated = true;
+ this.reconnectDelay = 1000;
+ this._authFailed = false;
+ this._setStatus("connected");
+ const payload = msg.payload || {};
+ this.serverInfo = payload.server;
+ this.features = payload.features;
+
+ // Store device token if provided
+ if (payload.auth?.deviceToken) {
+ try {
+ const identity = await getOrCreateDeviceIdentity();
+ if (identity) {
+ storeDeviceToken(identity.deviceId, "operator", payload.auth.deviceToken, payload.auth.scopes || []);
+ }
+ } catch {}
+ }
+
+ if (this._connectResolve) {
+ const res = this._connectResolve;
+ this._connectResolve = null;
+ this._connectReject = null;
+ res(this);
+ }
+ } else {
+ console.error("[GW] Connect REJECTED:", msg.error);
+ this._authFailed = true;
+ this.lastError = msg.error?.message || "Connect rejected";
+
+ // Clear device token on auth failure
+ try {
+ const identity = await getOrCreateDeviceIdentity();
+ if (identity) clearDeviceToken(identity.deviceId, "operator");
+ } catch {}
+
+ if (this._connectReject) {
+ const rej = this._connectReject;
+ this._connectResolve = null;
+ this._connectReject = null;
+ rej(new Error(this.lastError));
+ }
+
+ // Close WebSocket explicitly to prevent lingering connection
+ try { this.ws?.close(); } catch {}
+ }
+ return;
+ }
+
+ // RPC response
+ if (msg.type === "res" && msg.id && this.pendingRpc.has(msg.id)) {
+ const { resolve, reject } = this.pendingRpc.get(msg.id);
+ this.pendingRpc.delete(msg.id);
+ if (!msg.ok || msg.error) {
+ reject(new Error(msg.error?.message || JSON.stringify(msg.error)));
+ } else {
+ resolve(msg.payload ?? msg);
+ }
+ return;
+ }
+
+ // Event frames
+ if (msg.type === "event" && msg.event) {
+ if (msg.event === "tick") return;
+
+ const listeners = this.subscribers.get(msg.event) || [];
+ for (const cb of listeners) {
+ try { cb(msg.payload ?? msg); } catch {}
+ }
+ const wildcardListeners = this.subscribers.get("*") || [];
+ for (const cb of wildcardListeners) {
+ try { cb({ event: msg.event, ...(msg.payload ?? {}) }); } catch {}
+ }
+ }
+ }
+
+ async _sendConnectRequest(nonce) {
+ if (this._connectSent) return;
+ this._connectSent = true;
+
+ const role = "operator";
+ const scopes = ["operator.admin", "operator.read", "operator.write", "operator.approvals", "operator.pairing"];
+ const clientId = "webchat-ui";
+ const clientMode = "webchat";
+ let authToken = this.token;
+
+ // Build device identity for Ed25519 auth (required for scopes)
+ let deviceObj = null;
+ const hasSubtleCrypto = typeof crypto !== "undefined" && !!crypto.subtle;
+
+ if (hasSubtleCrypto) {
+ try {
+ const identity = await getOrCreateDeviceIdentity();
+ if (identity) {
+ // Try stored device token first (faster reconnect)
+ const storedToken = getStoredDeviceToken(identity.deviceId, role);
+ if (storedToken && this.token) {
+ // Prefer stored device token over shared gateway token
+ authToken = storedToken;
+ }
+
+ const signedAtMs = Date.now();
+ const payload = buildDeviceAuthPayload({
+ deviceId: identity.deviceId,
+ clientId,
+ clientMode,
+ role,
+ scopes,
+ signedAtMs,
+ token: authToken || null,
+ nonce: nonce || undefined,
+ version: nonce ? "v2" : "v1",
+ });
+ const signature = await signPayload(identity.privateKey, payload);
+
+ deviceObj = {
+ id: identity.deviceId,
+ publicKey: identity.publicKey,
+ signature,
+ signedAt: signedAtMs,
+ nonce: nonce || undefined,
+ };
+ }
+ } catch (e) {
+ console.warn("[GW] Device auth setup failed, falling back to token-only:", e);
+ }
+ }
+
+ console.log("[GW] Sending connect request, token present:", !!authToken, "nonce:", !!nonce, "device:", !!deviceObj);
+
+ this._send({
+ type: "req",
+ id: "connect",
+ method: "connect",
+ params: {
+ minProtocol: 3,
+ maxProtocol: 3,
+ client: {
+ id: clientId,
+ version: "1.0.0",
+ platform: navigator?.platform || "web",
+ mode: clientMode,
+ displayName: "Bates Command Center",
+ instanceId: generateUUID(),
+ },
+ role,
+ scopes,
+ device: deviceObj,
+ auth: {
+ token: authToken,
+ },
+ caps: ["tool-events"],
+ userAgent: navigator?.userAgent,
+ },
+ });
+ }
+
+ rpc(method, params = {}) {
+ return new Promise((resolve, reject) => {
+ if (!this.authenticated) {
+ reject(new Error("Not authenticated"));
+ return;
+ }
+ const id = `rpc-${++this.rpcIdCounter}`;
+ this.pendingRpc.set(id, { resolve, reject });
+ this._send({ type: "req", id, method, params });
+
+ setTimeout(() => {
+ if (this.pendingRpc.has(id)) {
+ this.pendingRpc.delete(id);
+ reject(new Error(`RPC timeout: ${method}`));
+ }
+ }, 15000);
+ });
+ }
+
+ subscribe(eventType, callback) {
+ if (!this.subscribers.has(eventType)) {
+ this.subscribers.set(eventType, []);
+ }
+ this.subscribers.get(eventType).push(callback);
+ return () => {
+ const list = this.subscribers.get(eventType);
+ if (list) {
+ const idx = list.indexOf(callback);
+ if (idx >= 0) list.splice(idx, 1);
+ }
+ };
+ }
+
+ _send(obj) {
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(obj));
+ }
+ }
+
+ _setStatus(status) {
+ if (this.onStatusChange) {
+ this.onStatusChange(status);
+ }
+ }
+
+ _reconnect() {
+ if (!this._shouldReconnect) return;
+ this._setStatus("reconnecting");
+ this.connect().catch(() => {});
+ }
+
+ disconnect() {
+ this._shouldReconnect = false;
+ if (this.ws) {
+ this.ws.close();
+ }
+ }
+}
+
+window.GatewayClient = GatewayClient;
diff --git a/bates-core/plugins/dashboard/static/js/panel-agents.js b/bates-core/plugins/dashboard/static/js/panel-agents.js
new file mode 100644
index 0000000..7bac102
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-agents.js
@@ -0,0 +1,569 @@
+/**
+ * Agents Panel — Org Chart Layout (v5 with management)
+ * Tiers loaded from API, SOUL/model editing, agent creation
+ */
+(function () {
+ const D = window.Dashboard;
+ let sessionData = [], subagentData = [], agentFleetData = [];
+ let fastRefreshInterval = null;
+
+ // Default tiers — overridden by API data
+ let TIERS = {
+ coo: [],
+ deputies: [],
+ specialists: [],
+ };
+
+ function authHeaders(extra) {
+ const h = {};
+ const token = window.__GATEWAY_CONFIG?.token;
+ if (token) h['Authorization'] = 'Bearer ' + token;
+ return Object.assign(h, extra || {});
+ }
+
+ window.AGENT_AVATARS = {};
+ const MODEL_FALLBACK = {};
+
+ function mbClass(m) { if (!m) return 'other'; const l = m.toLowerCase(); return l.includes('opus') ? 'opus' : l.includes('sonnet') ? 'sonnet' : l.includes('gemini') ? 'gemini' : l.includes('codex') ? 'codex' : 'other'; }
+ function ago(ep) { if (!ep) return 'never'; const d = Date.now()/1000-ep; return d<0?'now':d<60?((d|0)+'s ago'):d<3600?((d/60|0)+'m ago'):d<86400?((d/3600|0)+'h ago'):((d/86400|0)+'d ago'); }
+ function find(n) { return agentFleetData.find(a => a.name?.toLowerCase() === n.toLowerCase()); }
+
+ const API_ID_MAP = { bates: 'main' };
+ function apiId(id) { return API_ID_MAP[id] || id; }
+
+ function buildTiersFromFleet() {
+ // Build tiers from API data by reading layer from agent data
+ const coo = [], deputies = [], specialists = [];
+ const seen = new Set();
+ for (const a of agentFleetData) {
+ const rawId = a.id || a.name?.toLowerCase();
+ // Map 'main' to 'bates' for display, dedup
+ const entry = { id: rawId === 'main' ? 'bates' : rawId, name: a.name || a.id, role: a.role || '' };
+ if (seen.has(entry.id)) continue;
+ seen.add(entry.id);
+ if (a.id === 'main' || rawId === 'bates') {
+ entry.id = 'bates';
+ entry.name = a.name || 'Bates';
+ coo.push(entry);
+ } else if (a.layer === 2 || a.layer === '2') {
+ deputies.push(entry);
+ } else {
+ specialists.push(entry);
+ }
+ }
+ if (coo.length || deputies.length || specialists.length) {
+ TIERS = { coo, deputies, specialists };
+ }
+ }
+
+ // Load avatars from static assets
+ function loadAvatars() {
+ const avatarMap = {
+ bates: '/dashboard/assets/avatar-transparent.png',
+ conrad: '/dashboard/assets/agent-baby_bolt.png',
+ soren: '/dashboard/assets/agent-baby_core.png',
+ amara: '/dashboard/assets/agent-baby_aqua.png',
+ jules: '/dashboard/assets/agent-baby_frost.png',
+ dash: '/dashboard/assets/agent-baby_Ember.png',
+ mercer: '/dashboard/assets/agent-baby_Dark.png',
+ kira: '/dashboard/assets/agent-baby_pixel.png',
+ nova: '/dashboard/assets/agent-baby_nova.png',
+ paige: '/dashboard/assets/agent-baby_Sage.png',
+ quinn: '/dashboard/assets/agent-baby_sky.png',
+ mira: '/dashboard/assets/agent-baby_Sage.png',
+ archer: '/dashboard/assets/agent-baby_sky.png',
+ };
+ Object.assign(window.AGENT_AVATARS, avatarMap);
+ // Also check for uploaded custom avatars per agent
+ for (const a of agentFleetData) {
+ const id = a.id === 'main' ? 'bates' : a.id;
+ if (!avatarMap[id]) {
+ // Try common extensions
+ for (const ext of ['png', 'jpg', 'webp']) {
+ const img = new Image();
+ img.src = `/dashboard/assets/agent-${id}.${ext}`;
+ img.onload = () => { window.AGENT_AVATARS[id] = img.src; };
+ }
+ }
+ }
+ }
+
+ function openAgentDetail(id, name) {
+ const ov = document.getElementById('soul-modal-overlay'); if (!ov) return;
+ const allAgents = [...TIERS.coo, ...TIERS.deputies, ...TIERS.specialists];
+ const def = allAgents.find(a => a.id === id) || {};
+ const fleetAgent = find(name) || {};
+ const avatarSrc = window.AGENT_AVATARS[id] || '';
+ const m = fleetAgent.model || MODEL_FALLBACK[id] || '', cls = mbClass(m);
+ const st = fleetAgent.status || 'idle';
+
+ const titleEl = document.getElementById('soul-modal-title');
+ const bodyEl = document.getElementById('soul-modal-body');
+ titleEl.textContent = name + ' — Agent Detail';
+
+ bodyEl.innerHTML = `
+
+
+
+
+
+
+ ${id !== 'bates' ? '' : ''}
+
+
+
+
Recent Activity
+
Loading...
+
+
+
Recent Memory
+
Loading...
+
+
+
`;
+
+ ov.classList.add('visible');
+
+ // Fetch agent API data
+ D.fetchApi('agents').then(agents => {
+ const agents2 = Array.isArray(agents) ? agents : (agents?.agents || []);
+ const aid = apiId(id);
+ const a = agents2.find(x => x.id === aid || x.id === id || x.name?.toLowerCase() === name.toLowerCase());
+ const el = document.getElementById('agent-detail-activity');
+ if (a && el) {
+ const hb = a.heartbeat_interval || '—';
+ const lastAct = a.last_activity ? new Date(a.last_activity).toLocaleString() : 'never';
+ el.innerHTML = `Last activity: ${D.esc(lastAct)}
+ Heartbeat: ${D.esc(hb)}
+ Layer: ${a.layer || '—'}
`;
+ }
+ }).catch(() => { const el = document.getElementById('agent-detail-activity'); if (el) el.textContent = 'Could not load'; });
+
+ // Fetch SOUL.md
+ D.fetchApi(`agents/${encodeURIComponent(apiId(id))}/soul`).then(d => {
+ const el = document.getElementById('agent-detail-soul');
+ if (el) el.textContent = d?.content || 'No SOUL.md found.';
+ }).catch(() => { const el = document.getElementById('agent-detail-soul'); if (el) el.textContent = 'Error loading SOUL.md'; });
+
+ // Fetch today's memory
+ const today = new Date().toISOString().slice(0, 10);
+ D.fetchApi(`agents/${encodeURIComponent(apiId(id))}/memory?date=${today}`).then(d => {
+ const el = document.getElementById('agent-detail-memory');
+ if (el) {
+ const content = d?.content || d?.text || '';
+ if (content) {
+ const lines = content.split('\n');
+ el.textContent = lines.slice(-5).join('\n') || 'No entries today.';
+ } else { el.textContent = 'No memory entries today.'; }
+ }
+ }).catch(() => { const el = document.getElementById('agent-detail-memory'); if (el) el.textContent = 'No memory available.'; });
+
+ // Wire management buttons
+ document.getElementById('agent-edit-soul-btn')?.addEventListener('click', () => openSoulEditor(apiId(id), name));
+ document.getElementById('agent-edit-model-btn')?.addEventListener('click', () => openModelEditor(apiId(id), name, m));
+ document.getElementById('agent-edit-layer-btn')?.addEventListener('click', () => openLayerEditor(apiId(id), name));
+ document.getElementById('agent-delete-btn')?.addEventListener('click', async () => {
+ if (!confirm(`Archive agent "${name}"? This removes it from the config and archives the directory.`)) return;
+ try {
+ const resp = await fetch('/dashboard/api/agents/delete', {
+ method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ id: apiId(id) })
+ });
+ const r = await resp.json();
+ if (r.success) {
+ ov.classList.remove('visible');
+ showRestartBanner('Agent archived. Gateway restart required.');
+ await refreshFleet();
+ render();
+ } else { alert(r.error || 'Failed'); }
+ } catch (e) { alert('Error: ' + e.message); }
+ });
+ }
+ window._openSoulModal = openAgentDetail;
+
+ function showRestartBanner(msg) {
+ const el = document.getElementById('panel-agents');
+ if (!el) return;
+ let banner = el.querySelector('.restart-banner');
+ if (!banner) { banner = document.createElement('div'); banner.className = 'restart-banner'; el.prepend(banner); }
+ banner.innerHTML = `${D.esc(msg)}`;
+ }
+
+ function openSoulEditor(agentId, name) {
+ const soulEl = document.getElementById('agent-detail-soul');
+ if (!soulEl) return;
+ const currentContent = soulEl.textContent;
+ const sectionEl = soulEl.parentElement;
+ sectionEl.innerHTML = `
+
+
SOUL.md — Editing
+
+
+
+
+
+
+ `;
+
+ document.getElementById('soul-cancel-btn').addEventListener('click', () => {
+ sectionEl.innerHTML = `
+
+ ${D.esc(currentContent)}`;
+ document.getElementById('agent-edit-soul-btn')?.addEventListener('click', () => openSoulEditor(agentId, name));
+ });
+
+ document.getElementById('soul-save-btn').addEventListener('click', async () => {
+ const content = document.getElementById('soul-editor').value;
+ const msg = document.getElementById('soul-msg');
+ try {
+ const resp = await fetch('/dashboard/api/agents/update-soul', {
+ method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ id: agentId, content })
+ });
+ const r = await resp.json();
+ if (r.success) {
+ msg.style.color = '#22c55e';
+ msg.textContent = 'Saved successfully';
+ // Update the pre element
+ setTimeout(() => {
+ sectionEl.innerHTML = `
+
+ ${D.esc(content)}`;
+ document.getElementById('agent-edit-soul-btn')?.addEventListener('click', () => openSoulEditor(agentId, name));
+ }, 1000);
+ } else { msg.style.color = '#ef4444'; msg.textContent = r.error || 'Failed'; }
+ } catch (e) { msg.style.color = '#ef4444'; msg.textContent = 'Error: ' + e.message; }
+ });
+ }
+
+ function openModelEditor(agentId, name, currentModel) {
+ const ov = document.getElementById('soul-modal-overlay'); if (!ov) return;
+ const bodyEl = document.getElementById('soul-modal-body');
+ const titleEl = document.getElementById('soul-modal-title');
+ titleEl.textContent = name + ' — Change Model';
+ bodyEl.innerHTML = `
+ `;
+
+ document.getElementById('model-cancel-btn').addEventListener('click', () => openAgentDetail(agentId === 'main' ? 'bates' : agentId, name));
+ document.getElementById('model-save-btn').addEventListener('click', async () => {
+ const primary = document.getElementById('model-primary').value;
+ const fallback = document.getElementById('model-fallback').value;
+ const model = { primary };
+ if (fallback) model.fallbacks = [fallback];
+ try {
+ const resp = await fetch('/dashboard/api/agents/update-model', {
+ method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ id: agentId, model })
+ });
+ const r = await resp.json();
+ const msg = document.getElementById('model-msg');
+ if (r.success) {
+ msg.style.color = '#22c55e';
+ msg.textContent = 'Saved. Gateway restart required for changes to take effect.';
+ showRestartBanner('Model changed. Gateway restart required.');
+ } else { msg.style.color = '#ef4444'; msg.textContent = r.error || 'Failed'; }
+ } catch (e) { document.getElementById('model-msg').textContent = 'Error: ' + e.message; }
+ });
+ }
+
+ function openLayerEditor(agentId, name) {
+ const ov = document.getElementById('soul-modal-overlay'); if (!ov) return;
+ const bodyEl = document.getElementById('soul-modal-body');
+ const titleEl = document.getElementById('soul-modal-title');
+ titleEl.textContent = name + ' — Change Layer';
+ bodyEl.innerHTML = `
+ `;
+
+ document.getElementById('layer-cancel-btn').addEventListener('click', () => openAgentDetail(agentId === 'main' ? 'bates' : agentId, name));
+ document.getElementById('layer-save-btn').addEventListener('click', async () => {
+ const layer = parseInt(document.getElementById('layer-select').value);
+ try {
+ const resp = await fetch('/dashboard/api/agents/update-layer', {
+ method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ id: agentId, layer })
+ });
+ const r = await resp.json();
+ const msg = document.getElementById('layer-msg');
+ if (r.success) {
+ msg.style.color = '#22c55e';
+ msg.textContent = 'Layer updated.';
+ await refreshFleet();
+ render();
+ } else { msg.style.color = '#ef4444'; msg.textContent = r.error || 'Failed'; }
+ } catch (e) { document.getElementById('layer-msg').textContent = 'Error: ' + e.message; }
+ });
+ }
+
+ function openCreateAgent() {
+ const ov = document.getElementById('soul-modal-overlay'); if (!ov) return;
+ const titleEl = document.getElementById('soul-modal-title');
+ const bodyEl = document.getElementById('soul-modal-body');
+ titleEl.textContent = 'Create New Agent';
+ bodyEl.innerHTML = `
+ `;
+
+ // Avatar preview
+ document.getElementById('ca-avatar')?.addEventListener('change', (e) => {
+ const file = e.target.files[0];
+ const prev = document.getElementById('ca-avatar-preview');
+ if (file && prev) {
+ const reader = new FileReader();
+ reader.onload = () => { prev.innerHTML = `
`; };
+ reader.readAsDataURL(file);
+ }
+ });
+
+ document.getElementById('ca-cancel-btn').addEventListener('click', () => ov.classList.remove('visible'));
+ document.getElementById('ca-save-btn').addEventListener('click', async () => {
+ const data = {
+ id: document.getElementById('ca-id').value.trim().toLowerCase().replace(/[^a-z0-9-]/g, ''),
+ name: document.getElementById('ca-name').value.trim(),
+ role: document.getElementById('ca-role').value.trim(),
+ layer: parseInt(document.getElementById('ca-layer').value),
+ model: { primary: document.getElementById('ca-model').value },
+ };
+ if (!data.id || !data.name) { document.getElementById('ca-msg').textContent = 'ID and Name required'; return; }
+ try {
+ const resp = await fetch('/dashboard/api/agents/create', {
+ method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify(data)
+ });
+ const r = await resp.json();
+ const msg = document.getElementById('ca-msg');
+ if (r.success) {
+ // Upload avatar if provided
+ const avatarFile = document.getElementById('ca-avatar')?.files?.[0];
+ if (avatarFile) {
+ const formData = new FormData();
+ formData.append('avatar', avatarFile);
+ formData.append('id', data.id);
+ try {
+ await fetch('/dashboard/api/agents/upload-avatar', {
+ method: 'POST', headers: authHeaders(), body: formData
+ });
+ } catch (ae) { console.warn('Avatar upload failed:', ae); }
+ }
+ msg.style.color = '#22c55e';
+ msg.textContent = 'Agent created. Gateway restart required.';
+ showRestartBanner('New agent created. Gateway restart required.');
+ setTimeout(() => { ov.classList.remove('visible'); refreshFleet().then(render); }, 1500);
+ } else { msg.style.color = '#ef4444'; msg.textContent = r.error || 'Failed'; }
+ } catch (e) { document.getElementById('ca-msg').textContent = 'Error: ' + e.message; }
+ });
+ }
+
+ function card(def, isCoo) {
+ const d = find(def.name), st = d?.status || 'idle', m = d?.model || MODEL_FALLBACK[def.id] || '', cls = mbClass(m);
+ const avatarSrc = window.AGENT_AVATARS[def.id] || '';
+ const avatarHtml = avatarSrc ? `
` : '';
+ return `
+ ${avatarHtml}
+
${D.esc(def.name)}
+
${D.esc(d?.role || def.role)}
+ ${m ? `
${D.esc(m.split('/').pop())}` : ''}
+
${D.esc(ago(d?.last_activity_epoch))}
+
📥 ${d?.inbox_count||0}📤 ${d?.outbox_count||0}
+
💓 ${D.esc(d?.heartbeat_interval||'—')}
+
`;
+ }
+
+ function render() {
+ const el = document.getElementById('panel-agents'); if (!el) return;
+ let h = '';
+ if (TIERS.coo.length) {
+ h += 'Layer 1 — COO
' + TIERS.coo.map(a => card(a, true)).join('') + '
';
+ h += '';
+ }
+ if (TIERS.deputies.length) {
+ h += 'Layer 2 — Deputies
' + TIERS.deputies.map(a => card(a, false)).join('') + '
';
+ h += '';
+ }
+ if (TIERS.specialists.length) {
+ h += 'Layer 3 — Specialists
' + TIERS.specialists.map(a => card(a, false)).join('') + '
';
+ }
+
+ // Add "Create Agent" button
+ h += `
+
+
`;
+
+ el.innerHTML = h;
+
+ // Wire create agent button after innerHTML is set
+ document.getElementById('create-agent-btn')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ openCreateAgent();
+ });
+ }
+
+ async function refreshSub() { try { const d = await D.fetchApi('sessions'); if (Array.isArray(d)) subagentData = d; } catch {} }
+ async function refreshFleet() {
+ try {
+ const r = await D.fetchApi('agents');
+ agentFleetData = Array.isArray(r) ? r : (r?.agents || []);
+ buildTiersFromFleet();
+ } catch {}
+ }
+
+ async function refresh(gw) {
+ if (gw?.authenticated) try { const r = await gw.rpc('sessions.list', {}); sessionData = r?.sessions || r?.items || (Array.isArray(r) ? r : []); } catch { sessionData = []; }
+ await Promise.all([refreshSub(), refreshFleet()]);
+ const a = agentFleetData.filter(x => x.status === 'active').length;
+ const ready = agentFleetData.filter(x => x.status === 'ready' || x.status === 'active').length;
+ window._updateOverviewMetrics?.({ activeAgents: a + '/' + ready });
+ render();
+ }
+
+ async function init(gw) {
+ loadAvatars();
+
+ // Event delegation for Create Agent button (survives innerHTML rebuilds)
+ const panel = document.getElementById('panel-agents');
+ if (panel) {
+ panel.addEventListener('click', (e) => {
+ const btn = e.target.closest('#create-agent-btn');
+ if (btn) {
+ e.stopPropagation();
+ openCreateAgent();
+ }
+ });
+ }
+
+ render();
+ if (gw?.authenticated) await refresh(gw); else await Promise.all([refreshSub(), refreshFleet()]);
+ render();
+ gw?.subscribe('agent', () => refresh(gw));
+ gw?.subscribe('agent.lifecycle', () => refresh(gw));
+ }
+
+ let _refreshInterval = null;
+ let _lastUpdated = null;
+
+ function updateTimestamp() {
+ const el = document.getElementById('panel-agents');
+ if (!el) return;
+ let ts = el.querySelector('.panel-last-updated');
+ if (!ts) { ts = document.createElement('div'); ts.className = 'panel-last-updated'; el.appendChild(ts); }
+ if (_lastUpdated) {
+ const s = ((Date.now() - _lastUpdated) / 1000) | 0;
+ ts.textContent = `last updated: ${s}s ago`;
+ }
+ }
+
+ const _origRefresh = refresh;
+ refresh = async function(gw) {
+ await _origRefresh(gw);
+ _lastUpdated = Date.now();
+ updateTimestamp();
+ };
+
+ function startAutoRefresh(gw) {
+ stopAutoRefresh();
+ _refreshInterval = setInterval(() => { refresh(gw); }, 60000);
+ setInterval(updateTimestamp, 10000);
+ }
+ function stopAutoRefresh() { if (_refreshInterval) { clearInterval(_refreshInterval); _refreshInterval = null; } }
+
+ const _origInit = init;
+ init = async function(gw) {
+ await _origInit(gw);
+ startAutoRefresh(gw);
+ };
+
+ window._openCreateAgent = openCreateAgent;
+
+ D.registerPanel('agents', { init, refresh, stopAutoRefresh });
+})();
diff --git a/bates-core/plugins/dashboard/static/js/panel-ceo.js b/bates-core/plugins/dashboard/static/js/panel-ceo.js
new file mode 100644
index 0000000..bb0b90c
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-ceo.js
@@ -0,0 +1,91 @@
+/**
+ * CEO Dashboard Panel — Tasks + project data + metrics (v4)
+ */
+(function () {
+ const D = window.Dashboard;
+
+ function priClass(p) { return p === 'high' || p === 1 ? 'high' : p === 'medium' || p === 5 ? 'medium' : p === 'low' || p === 9 ? 'low' : 'none'; }
+
+ function renderTasks(tasks) {
+ const el = document.getElementById('panel-ceo-tasks');
+ if (!el) return;
+ if (!tasks?.length) {
+ el.innerHTML = 'No tasks found
No tasks loaded
';
+ return;
+ }
+ let h = '';
+ for (const t of tasks) {
+ const done = t.status === 'completed' || t.completed;
+ h += `
+
+
+
+
${D.esc(t.title || t.subject || '—')}
+
+ ${t.dueDate ? `Due: ${D.esc(t.dueDate)}` : ''}
+ ${t.planName ? `${D.esc(t.planName)}` : ''}
+ ${t.source ? `${D.esc(t.source)}` : ''}
+
+
+
`;
+ }
+ el.innerHTML = h;
+ const pending = tasks.filter(t => !t.completed && t.status !== 'completed').length;
+ window._updateOverviewMetrics?.({ tasks: pending });
+ }
+
+ function renderProjectBodies(agents, tasksData) {
+ const projects = [
+ { el: 'project-project_a', agent: 'conrad', key: 'project_a' },
+ { el: 'project-project_b', agent: 'soren', key: 'project_b' },
+ { el: 'project-private', agent: 'jules', key: 'private' },
+ { el: 'project-project_c', agent: 'amara', key: 'project_c' },
+ { el: 'project-bates', agent: 'dash', key: 'bates' },
+ ];
+ const byProject = tasksData?.byProject || {};
+ for (const p of projects) {
+ const container = document.getElementById(p.el);
+ if (!container) continue;
+ const a = agents?.find(x => x.name?.toLowerCase() === p.agent);
+ const proj = byProject[p.key];
+ let html = '';
+ if (a) {
+ html += ` ${D.esc(a.status||'idle')} · Last: ${D.esc(D.timeAgo(a.lastHeartbeat||a.last_heartbeat||a.last_activity))}`;
+ }
+ if (proj) {
+ const pending = (proj.tasks || []).filter(t => !t.completed).length;
+ html += `📋 ${proj.count || 0} tasks (${pending} pending)
`;
+ }
+ container.innerHTML = html || 'No data';
+ }
+ }
+
+ async function refresh() {
+ let tasks = null, status = null, agents = null;
+ try {
+ const [tR, sR, aR] = await Promise.allSettled([
+ D.fetchApi('tasks'),
+ D.fetchApi('status'),
+ D.fetchApi('agents'),
+ ]);
+ tasks = tR.status === 'fulfilled' ? tR.value : null;
+ status = sR.status === 'fulfilled' ? sR.value : null;
+ agents = aR.status === 'fulfilled' ? aR.value : null;
+ } catch {}
+
+ let list = tasks ? (Array.isArray(tasks) ? tasks : (tasks.tasks || tasks.items || [])) : [];
+ // Only render in CEO panel if tasks panel isn't handling it
+ if (list.length) {
+ // Update metrics from real data
+ const pending = list.filter(t => !t.completed && t.status !== 'completed').length;
+ window._updateOverviewMetrics?.({ tasks: pending });
+ }
+
+ if (status?.unread_emails !== undefined) window._updateOverviewMetrics?.({ emails: status.unread_emails });
+
+ const agentList = agents ? (Array.isArray(agents) ? agents : (agents.agents || [])) : [];
+ renderProjectBodies(agentList, tasks);
+ }
+
+ D.registerPanel('ceo', { init: refresh, refresh });
+})();
diff --git a/bates-core/plugins/dashboard/static/js/panel-chat.js b/bates-core/plugins/dashboard/static/js/panel-chat.js
new file mode 100644
index 0000000..24a733d
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-chat.js
@@ -0,0 +1,426 @@
+/**
+ * Chat Panel
+ * Interactive chat with agent sessions via WebSocket RPC
+ */
+(function () {
+ const D = window.Dashboard;
+
+ let sessions = [];
+ let activeSessionKey = null;
+ let messages = [];
+ let streamingText = "";
+ let activeRunId = null;
+ let isStreaming = false;
+ let unsubChat = null;
+ let gwRef = null;
+
+ function generateUUID() {
+ if (crypto.randomUUID) return crypto.randomUUID();
+ return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
+ (+c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> +c / 4))).toString(16)
+ );
+ }
+
+ function extractText(content) {
+ if (!content) return "";
+ if (typeof content === "string") return content;
+ if (Array.isArray(content)) {
+ const texts = content
+ .filter((b) => b && b.type === "text" && b.text)
+ .map((b) => b.text);
+ if (texts.length) return texts.join("\n");
+ // Fallback: try to extract any string values from array items
+ return content
+ .map((b) => (typeof b === "string" ? b : b && b.text ? b.text : ""))
+ .filter(Boolean)
+ .join("\n");
+ }
+ // Handle nested content (e.g., {content: "text"} or {content: [{type:"text", text:"..."}]})
+ if (content.content !== undefined) return extractText(content.content);
+ if (content.text) return String(content.text);
+ if (content.message) return String(content.message);
+ // Last resort: try JSON but never return [object Object]
+ try {
+ const s = JSON.stringify(content);
+ return s !== "{}" ? s : "";
+ } catch {
+ return "";
+ }
+ }
+
+ function renderSessionTabs() {
+ const bar = document.getElementById("chat-session-bar");
+ if (!bar) return;
+ if (!sessions.length) {
+ bar.innerHTML = 'No sessions available';
+ return;
+ }
+ const sorted = [...sessions].sort((a, b) => {
+ // Main session always first
+ const aIsMain = (a.key || "") === "agent:main:main";
+ const bIsMain = (b.key || "") === "agent:main:main";
+ if (aIsMain !== bIsMain) return aIsMain ? -1 : 1;
+ // Subagents last
+ const aIsSub = (a.key || "").startsWith("subagent:");
+ const bIsSub = (b.key || "").startsWith("subagent:");
+ if (aIsSub !== bIsSub) return aIsSub ? 1 : -1;
+ return (b.updatedAt || 0) - (a.updatedAt || 0);
+ });
+ let html = "";
+ for (const s of sorted) {
+ const key = s.key || "";
+ const label = s.displayName || s.label || key.split(":").pop() || "Unknown";
+ const isActive = key === activeSessionKey;
+ const isSub = key.startsWith("subagent:");
+ const isRunning = s.updatedAt && Date.now() - s.updatedAt < 300000;
+ html += ``;
+ }
+ bar.innerHTML = html;
+ }
+
+ function renderMessages() {
+ const el = document.getElementById("chat-messages");
+ if (!el) return;
+
+ if (!messages.length && !streamingText && !isStreaming) {
+ el.innerHTML =
+ '💬Select a session to begin
';
+ return;
+ }
+
+ let html = "";
+ for (const msg of messages) {
+ const text = extractText(msg.content);
+ if (!text) continue;
+ const role = msg.role || "system";
+ const ts = msg.timestamp
+ ? new Date(typeof msg.timestamp === "number" ? msg.timestamp : msg.timestamp).toLocaleTimeString("en-GB", {
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+ : "";
+ html += ``;
+ html += `
${D.esc(text)}
`;
+ if (ts) html += `
${ts}
`;
+ html += `
`;
+ }
+
+ if (isStreaming && streamingText) {
+ html += ``;
+ html += `
${D.esc(streamingText)}
`;
+ html += `
`;
+ } else if (isStreaming) {
+ html += ``;
+ html += `
Thinking...
`;
+ html += `
`;
+ }
+
+ el.innerHTML = html;
+
+ const scrollContainer = document.getElementById("chat-messages-scroll");
+ if (scrollContainer) scrollContainer.scrollTop = scrollContainer.scrollHeight;
+
+ updateInputBar();
+ }
+
+ function updateInputBar() {
+ const sendBtn = document.getElementById("chat-send-btn");
+ const stopBtn = document.getElementById("chat-stop-btn");
+ if (sendBtn) sendBtn.style.display = isStreaming ? "none" : "";
+ if (stopBtn) stopBtn.style.display = isStreaming ? "" : "none";
+ }
+
+ async function loadHistory(gw) {
+ if (!gw || !gw.authenticated || !activeSessionKey) {
+ messages = [];
+ renderMessages();
+ return;
+ }
+ try {
+ console.log("[Chat] Requesting chat.history for session:", activeSessionKey);
+ const result = await gw.rpc("chat.history", { sessionKey: activeSessionKey, limit: 200 });
+ console.log("[Chat] chat.history result keys:", result ? Object.keys(result) : "null");
+ const raw = result?.messages || [];
+ // Filter to user and assistant messages with actual text content
+ messages = raw.filter(m => {
+ const text = extractText(m.content);
+ return text && text.trim().length > 0 && (m.role === "user" || m.role === "assistant");
+ });
+ console.log("[Chat] Loaded", raw.length, "raw messages,", messages.length, "after filtering");
+ if (raw.length > 0 && messages.length === 0) {
+ console.log("[Chat] All messages filtered out. Sample roles:", raw.slice(0, 5).map(m => m.role));
+ console.log("[Chat] Sample message:", JSON.stringify(raw[0]).slice(0, 300));
+ }
+ } catch (e) {
+ console.error("[Chat] chat.history failed:", e);
+ messages = [];
+ }
+ streamingText = "";
+ isStreaming = false;
+ activeRunId = null;
+ renderMessages();
+ }
+
+ function subscribeToChatEvents(gw) {
+ if (unsubChat) {
+ unsubChat();
+ unsubChat = null;
+ }
+ if (!gw) return;
+ unsubChat = gw.subscribe("chat", (payload) => {
+ if (payload.sessionKey !== activeSessionKey) return;
+ const state = payload.state;
+
+ if (state === "delta") {
+ isStreaming = true;
+ activeRunId = payload.runId || activeRunId;
+ const text = extractText(payload.message);
+ // Deltas from gateway are CUMULATIVE (full text so far) — always replace
+ if (text) streamingText = text;
+ renderMessages();
+ } else if (state === "final") {
+ isStreaming = false;
+ streamingText = "";
+ activeRunId = null;
+ loadHistory(gw);
+ } else if (state === "aborted" || state === "error") {
+ isStreaming = false;
+ streamingText = "";
+ activeRunId = null;
+ if (state === "error" && payload.errorMessage) {
+ messages.push({ role: "system", content: "Error: " + payload.errorMessage });
+ }
+ loadHistory(gw);
+ }
+ });
+ }
+
+ async function selectSession(gw, sessionKey) {
+ activeSessionKey = sessionKey;
+ streamingText = "";
+ isStreaming = false;
+ activeRunId = null;
+ renderSessionTabs();
+ await loadHistory(gw);
+ subscribeToChatEvents(gw);
+ }
+
+ async function sendMessage(gw) {
+ const input = document.getElementById("chat-input");
+ if (!input) return;
+ const text = input.value.trim();
+ if (!text || !activeSessionKey || !gw || !gw.authenticated) return;
+
+ input.value = "";
+ input.style.height = "auto";
+
+ // Optimistic local append
+ messages.push({ role: "user", content: text, timestamp: Date.now() });
+ isStreaming = true;
+ streamingText = "";
+ renderMessages();
+
+ try {
+ const result = await gw.rpc("chat.send", {
+ sessionKey: activeSessionKey,
+ message: text,
+ deliver: false,
+ idempotencyKey: generateUUID(),
+ });
+ activeRunId = result?.runId || null;
+ } catch (e) {
+ console.error("chat.send failed:", e);
+ isStreaming = false;
+ messages.push({ role: "system", content: "Failed to send: " + e.message });
+ renderMessages();
+ }
+ }
+
+ async function abortAgent(gw) {
+ if (!gw || !gw.authenticated || !activeSessionKey) return;
+ try {
+ await gw.rpc("chat.abort", {
+ sessionKey: activeSessionKey,
+ runId: activeRunId || undefined,
+ });
+ } catch (e) {
+ console.error("chat.abort failed:", e);
+ }
+ isStreaming = false;
+ streamingText = "";
+ activeRunId = null;
+ renderMessages();
+ }
+
+ async function refreshSessions(gw) {
+ if (!gw || !gw.authenticated) {
+ console.log("[Chat] refreshSessions skipped — gw:", !!gw, "authenticated:", gw?.authenticated);
+ return;
+ }
+ try {
+ console.log("[Chat] Calling sessions.list...");
+ const result = await gw.rpc("sessions.list", {});
+ console.log("[Chat] sessions.list result keys:", result ? Object.keys(result) : "null");
+ const payload = result?.sessions || result?.items || (Array.isArray(result) ? result : []);
+ sessions = Array.isArray(payload) ? payload : [];
+ console.log("[Chat] Got", sessions.length, "sessions");
+ } catch (e) {
+ console.error("[Chat] sessions.list failed:", e);
+ sessions = [];
+ }
+ // Always ensure main session is available for chat
+ if (!sessions.find(s => s.key === "agent:main:main")) {
+ sessions.unshift({ key: "agent:main:main", displayName: "Main", label: "main", updatedAt: Date.now() });
+ }
+ renderSessionTabs();
+
+ // If selected session disappeared, clear
+ if (activeSessionKey && !sessions.find((s) => s.key === activeSessionKey)) {
+ activeSessionKey = null;
+ messages = [];
+ streamingText = "";
+ isStreaming = false;
+ activeRunId = null;
+ renderMessages();
+ const input = document.getElementById("chat-input");
+ const sendBtn = document.getElementById("chat-send-btn");
+ if (input) input.disabled = true;
+ if (sendBtn) sendBtn.disabled = true;
+ }
+ }
+
+ function showConnStatus(msg, type) {
+ const el = document.getElementById("chat-conn-status");
+ if (!el) return;
+ el.textContent = msg;
+ el.className = "chat-conn-status chat-conn-" + (type || "info");
+ el.style.display = msg ? "block" : "none";
+ }
+
+ async function init(gw) {
+ gwRef = gw;
+ const el = document.getElementById("panel-chat");
+ if (!el) return;
+
+ el.innerHTML = `
+
+
+
+
+
+
+
+
+ `;
+
+ // Session tab click handler
+ const bar = document.getElementById("chat-session-bar");
+ bar.addEventListener("click", (e) => {
+ const tab = e.target.closest(".chat-session-tab");
+ if (!tab) return;
+ const key = tab.dataset.sessionKey;
+ if (key) {
+ selectSession(gw, key);
+ const input = document.getElementById("chat-input");
+ const sendBtn = document.getElementById("chat-send-btn");
+ if (input) input.disabled = false;
+ if (sendBtn) sendBtn.disabled = false;
+ }
+ });
+
+ // Send button
+ document.getElementById("chat-send-btn").addEventListener("click", () => sendMessage(gw));
+
+ // Stop button
+ document.getElementById("chat-stop-btn").addEventListener("click", () => abortAgent(gw));
+
+ // Textarea: Enter to send, Shift+Enter for newline, auto-resize
+ const input = document.getElementById("chat-input");
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ sendMessage(gw);
+ }
+ });
+ input.addEventListener("input", () => {
+ input.style.height = "auto";
+ input.style.height = Math.min(input.scrollHeight, 120) + "px";
+ });
+
+ // Track connection status in chat panel
+ if (gw) {
+ const origOnStatus = gw.onStatusChange;
+ gw.onStatusChange = function(status) {
+ if (origOnStatus) origOnStatus(status);
+ if (status === "connected") {
+ showConnStatus("Connected", "ok");
+ setTimeout(() => showConnStatus("", "ok"), 2000);
+ // Re-initialize chat on connect/reconnect
+ loadAndAutoSelect().catch(() => {});
+ if (activeSessionKey) subscribeToChatEvents(gw);
+ } else if (status === "reconnecting") {
+ showConnStatus("Reconnecting... (attempt " + (gw._retryCount + 1) + "/" + gw._maxRetries + ")", "warn");
+ } else if (status === "auth_failed") {
+ showConnStatus("WebSocket auth failed. Connection paused. " + (gw.lastError || ""), "error");
+ } else if (status === "max_retries") {
+ showConnStatus("Connection failed after " + gw._maxRetries + " attempts. Refresh page to retry.", "error");
+ } else if (status === "disconnected") {
+ showConnStatus("Disconnected", "warn");
+ }
+ };
+ }
+
+ // Load sessions and auto-select main (with retry for auth timing)
+ async function loadAndAutoSelect() {
+ console.log("[Chat] loadAndAutoSelect — gw:", !!gw, "authenticated:", gw?.authenticated, "connected:", gw?.connected);
+ if (!gw || !gw.authenticated) return false;
+ showConnStatus("Connected", "ok");
+ setTimeout(() => showConnStatus("", "ok"), 2000);
+ await refreshSessions(gw);
+ if (!activeSessionKey && sessions.length > 0) {
+ const main = sessions.find((s) => s.key === "agent:main:main") || sessions[0];
+ if (main) {
+ await selectSession(gw, main.key);
+ input.disabled = false;
+ document.getElementById("chat-send-btn").disabled = false;
+ }
+ }
+ return true;
+ }
+
+ showConnStatus("Connecting to gateway...", "info");
+
+ if (!(await loadAndAutoSelect())) {
+ // Auth not ready yet — retry up to 10 times
+ let retries = 0;
+ const retryInterval = setInterval(async () => {
+ retries++;
+ if (await loadAndAutoSelect() || retries >= 10) {
+ clearInterval(retryInterval);
+ if (retries >= 10 && (!gw || !gw.authenticated)) {
+ showConnStatus("Connection failed — retrying in background", "error");
+ }
+ }
+ }, 500);
+ }
+
+ // Subscribe to lifecycle events
+ if (gw) {
+ gw.subscribe("agent", () => refreshSessions(gw));
+ }
+ }
+
+ async function refresh(gw) {
+ gwRef = gw;
+ await refreshSessions(gw);
+ if (activeSessionKey) subscribeToChatEvents(gw);
+ }
+
+ D.registerPanel("chat", { init, refresh });
+})();
diff --git a/bates-core/plugins/dashboard/static/js/panel-community.js b/bates-core/plugins/dashboard/static/js/panel-community.js
new file mode 100644
index 0000000..6cdc85c
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-community.js
@@ -0,0 +1,159 @@
+/**
+ * Panel: Community — GitHub stars, social sharing, referral, newsletter
+ */
+(function () {
+ const GITHUB_REPO = 'getBates/Bates';
+ const GITHUB_URL = 'https://github.com/' + GITHUB_REPO;
+ const SITE_URL = 'https://getBates.ai';
+ const REFERRAL_BASE = SITE_URL + '/r/';
+ const NEWSLETTER_URL = SITE_URL + '/newsletter';
+
+ const SHARE_TEXT = 'Meet Bates — my AI assistant that manages email, calendar, tasks, and more through Microsoft 365. Open source!';
+ const SHARE_HASHTAGS = 'AI,Bates,OpenSource,Productivity';
+
+ let cachedStars = null;
+ let cachedStarsAt = 0;
+
+ async function fetchGitHubStars() {
+ if (cachedStars !== null && Date.now() - cachedStarsAt < 600000) return cachedStars;
+ try {
+ const resp = await fetch('https://api.github.com/repos/' + GITHUB_REPO);
+ if (resp.ok) {
+ const data = await resp.json();
+ cachedStars = data.stargazers_count || 0;
+ cachedStarsAt = Date.now();
+ return cachedStars;
+ }
+ } catch (e) { console.warn('GitHub stars fetch failed:', e); }
+ return cachedStars || 0;
+ }
+
+ function getReferralId() {
+ let id = localStorage.getItem('bates-referral-id');
+ if (!id) {
+ id = Math.random().toString(36).slice(2, 10);
+ localStorage.setItem('bates-referral-id', id);
+ }
+ return id;
+ }
+
+ function shareUrl(platform) {
+ const url = encodeURIComponent(SITE_URL);
+ const text = encodeURIComponent(SHARE_TEXT);
+ const hashtags = encodeURIComponent(SHARE_HASHTAGS);
+ const links = {
+ twitter: 'https://twitter.com/intent/tweet?text=' + text + '&url=' + url + '&hashtags=' + hashtags,
+ linkedin: 'https://www.linkedin.com/sharing/share-offsite/?url=' + url,
+ facebook: 'https://www.facebook.com/sharer/sharer.php?u=' + url,
+ threads: 'https://threads.net/intent/post?text=' + encodeURIComponent(SHARE_TEXT + ' ' + SITE_URL),
+ reddit: 'https://reddit.com/submit?url=' + url + '&title=' + encodeURIComponent('Bates — Open Source AI Assistant for Microsoft 365'),
+ hackernews:'https://news.ycombinator.com/submitlink?u=' + url + '&t=' + encodeURIComponent('Bates — Open Source AI Assistant for Microsoft 365'),
+ whatsapp: 'https://wa.me/?text=' + encodeURIComponent(SHARE_TEXT + ' ' + SITE_URL),
+ telegram: 'https://t.me/share/url?url=' + url + '&text=' + text,
+ email: 'mailto:?subject=' + encodeURIComponent('Check out Bates — AI Assistant') + '&body=' + encodeURIComponent(SHARE_TEXT + '\n\n' + SITE_URL),
+ };
+ return links[platform] || '#';
+ }
+
+ function socialBtn(platform, label, color, icon) {
+ return '' +
+ icon + ' ' + label + '';
+ }
+
+ async function render() {
+ const el = document.getElementById('panel-community');
+ if (!el) return;
+
+ const stars = await fetchGitHubStars();
+ const referralUrl = REFERRAL_BASE + getReferralId();
+
+ el.innerHTML =
+ '' +
+
+ // Row 1: GitHub + Share side by side
+ '
' +
+
+ // GitHub Stars card
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
Star on GitHub
' +
+ '
Help others discover Bates
' +
+ '
' +
+ '
' +
+ '
' + stars + '
' +
+ '
stars
' +
+ '
' +
+ '
' +
+ '
' +
+ '⭐ Star getBates/Bates' +
+ '
' +
+
+ // Share card
+ '
' +
+ '
Share Bates
' +
+ '
Know someone who\'d love their own AI assistant? Spread the word!
' +
+ '
' +
+ socialBtn('twitter', 'X / Twitter', '#000', '
') +
+ socialBtn('linkedin', 'LinkedIn', '#0a66c2', '
') +
+ socialBtn('facebook', 'Facebook', '#1877f2', '
') +
+ socialBtn('threads', 'Threads', '#000', '
') +
+ socialBtn('reddit', 'Reddit', '#ff4500', '
') +
+ socialBtn('hackernews', 'Hacker News', '#f06722', '
') +
+ socialBtn('whatsapp', 'WhatsApp', '#25d366', '
') +
+ socialBtn('telegram', 'Telegram', '#0088cc', '
') +
+ socialBtn('email', 'Email', '#666', '
') +
+ '
' +
+ '
' +
+
+ '
' +
+
+ // Row 2: Referral + Newsletter side by side
+ '
' +
+
+ // Referral
+ '
' +
+ '
Referral Link
' +
+ '
Share your personal link to track referrals
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+
+ // Newsletter
+ '
' +
+
+ '
' +
+
+ '
';
+ }
+
+ window._copyReferral = function () {
+ const input = document.getElementById('community-referral-url');
+ if (input) {
+ navigator.clipboard.writeText(input.value).then(function () {
+ var btn = input.nextElementSibling;
+ if (btn) { btn.textContent = 'Copied!'; setTimeout(function () { btn.textContent = 'Copy'; }, 2000); }
+ });
+ }
+ };
+
+ Dashboard.registerPanel('community', { refresh: render });
+ render();
+})();
diff --git a/bates-core/plugins/dashboard/static/js/panel-costs.js b/bates-core/plugins/dashboard/static/js/panel-costs.js
new file mode 100644
index 0000000..c25192a
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-costs.js
@@ -0,0 +1,153 @@
+/**
+ * Costs Panel — Real-time Token Usage & Operational Costs
+ */
+(function () {
+ const D = window.Dashboard;
+
+ function fmt(n) {
+ if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
+ return String(n);
+ }
+
+ function fmtDollar(n) { return '$' + n.toFixed(2); }
+
+ function todayKey() {
+ const d = new Date();
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
+ }
+
+ function render(data) {
+ const el = document.getElementById('panel-costs');
+ if (!el) return;
+
+ if (!data || data.error) {
+ el.innerHTML = '⏳ Awaiting data...
';
+ return;
+ }
+
+ const today = todayKey();
+ const todayData = data[today];
+
+ // 7-day aggregation
+ let tokens7 = 0, cost7 = 0, interactions7 = 0;
+ const now = new Date();
+ for (let i = 0; i < 7; i++) {
+ const d = new Date(now);
+ d.setDate(d.getDate() - i);
+ const k = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
+ if (data[k]) {
+ tokens7 += data[k].totalTokens || 0;
+ cost7 += data[k].totalCost || 0;
+ interactions7 += data[k].interactions || 0;
+ }
+ }
+
+ let h = '';
+
+ // Today's summary
+ if (todayData) {
+ h += `
+
Today's Usage
+
${fmt(todayData.totalTokens)} tokens
+
${(todayData.interactions || 0).toLocaleString()} interactions · Notional: ${fmtDollar(todayData.totalCost || 0)}
+
`;
+ } else {
+ h += `
+
Today's Usage
+
No data yet
+
`;
+ }
+
+ // 7-day summary
+ h += `
+
7-Day Total
+
${fmt(tokens7)} tokens
+
${interactions7.toLocaleString()} interactions · Notional: ${fmtDollar(cost7)}
+
`;
+
+ // Non-Anthropic cost note (only if there are non-Anthropic costs)
+ const nonAnthCost = todayData ? getNonAnthropicCost(todayData) : 0;
+ if (nonAnthCost > 0) {
+ h += `
+ 💰 Non-Anthropic API cost today: ${fmtDollar(nonAnthCost)}
+
`;
+ }
+
+ // Model breakdown for today
+ if (todayData && todayData.byModel) {
+ h += '';
+ h += '
ModelTokensNotional
';
+ const models = Object.entries(todayData.byModel)
+ .filter(([, v]) => v.tokens > 0 || v.count > 0)
+ .sort((a, b) => b[1].tokens - a[1].tokens);
+ for (const [name, v] of models) {
+ const badge = `
${fmtDollar(v.cost)}`;
+ h += `
+
+
${D.esc(name)}
+
${v.count} calls
+
+
${fmt(v.tokens)}
+
${badge}
+
`;
+ }
+ h += '
';
+ }
+
+ el.innerHTML = h;
+ }
+
+ function getNonAnthropicCost(dayData) {
+ if (!dayData || !dayData.byModel) return 0;
+ let cost = 0;
+ for (const [name, v] of Object.entries(dayData.byModel)) {
+ if (!name.startsWith('claude-')) cost += v.cost || 0;
+ }
+ return cost;
+ }
+
+ async function refresh() {
+ try {
+ const data = await D.fetchApi('costs');
+ if (data) { render(data); return; }
+ } catch {}
+ render(null);
+ }
+
+ let _refreshInterval = null;
+ let _lastUpdated = null;
+
+ function updateTimestamp() {
+ const el = document.getElementById('panel-costs');
+ if (!el) return;
+ let ts = el.querySelector('.panel-last-updated');
+ if (!ts) { ts = document.createElement('div'); ts.className = 'panel-last-updated'; el.appendChild(ts); }
+ if (_lastUpdated) {
+ const s = ((Date.now() - _lastUpdated) / 1000) | 0;
+ ts.textContent = `last updated: ${s}s ago`;
+ }
+ }
+
+ const _origRefresh = refresh;
+ async function autoRefresh() {
+ await _origRefresh();
+ _lastUpdated = Date.now();
+ updateTimestamp();
+ }
+
+ function startAutoRefresh() {
+ stopAutoRefresh();
+ _refreshInterval = setInterval(autoRefresh, 60000);
+ setInterval(updateTimestamp, 10000);
+ }
+ function stopAutoRefresh() { if (_refreshInterval) { clearInterval(_refreshInterval); _refreshInterval = null; } }
+
+ async function initPanel() {
+ await autoRefresh();
+ startAutoRefresh();
+ }
+
+ D.registerPanel('costs', { init: initPanel, refresh: autoRefresh, stopAutoRefresh });
+})();
diff --git a/bates-core/plugins/dashboard/static/js/panel-crons.js b/bates-core/plugins/dashboard/static/js/panel-crons.js
new file mode 100644
index 0000000..b96ed48
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-crons.js
@@ -0,0 +1,144 @@
+/**
+ * Cron Jobs Panel — Categorized card grid (v4)
+ * Excludes heartbeats from upcoming section on overview
+ */
+(function () {
+ const D = window.Dashboard;
+
+ function cronH(e) {
+ if (!e) return ''; const p = e.split(' '); if (p.length < 5) return e;
+ const [m, h, , , d] = p;
+ if (e.startsWith('0 */')) return `Every ${p[1].replace('*/','')}h`;
+ if (e.startsWith('*/')) return `Every ${m.replace('*/','')}m`;
+ if (d === '1-5') return `Weekdays ${h}:${m.padStart(2,'0')}`;
+ if (d === '1') return `Mon ${h}:${m.padStart(2,'0')}`;
+ if (d === '5') return `Fri ${h}:${m.padStart(2,'0')}`;
+ if (d === '*') { if (h.includes('-')) return `Daily ${h} at :${m.padStart(2,'0')}`; return `Daily ${h}:${m.padStart(2,'0')}`; }
+ return e;
+ }
+ function evH(ms) { if (!ms) return ''; const s = Math.round(ms/1000); return s<60?`Every ${s}s`:s<3600?`Every ${Math.round(s/60)}m`:`Every ${(s/3600).toFixed(1).replace(/\.0$/,'')}h`; }
+ function fmtTs(ms) {
+ if (!ms) return '—'; const d = new Date(ms), pad = n => String(n).padStart(2,'0');
+ const ds = `${pad(d.getDate())}/${pad(d.getMonth()+1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ const diff = ms - Date.now(), a = Math.abs(diff);
+ if (a < 864e5) { const h = (a/36e5)|0, m = ((a%36e5)/6e4)|0; return `${ds} (${diff>0?'in ':''}${h}h${m}m${diff<=0?' ago':''})`; }
+ return ds;
+ }
+ function isHeartbeat(j) {
+ const n = (j.name||j.id||'').toLowerCase();
+ return n.includes('heartbeat') || n.includes('hb-') || n.includes('checkin');
+ }
+ function cat(j) {
+ if (isHeartbeat(j)) return 'Agent Heartbeats';
+ const n = (j.name||j.id||'').toLowerCase();
+ if (n.includes('report')||n.includes('standup')||n.includes('digest')) return 'Scheduled Reports';
+ return 'System Tasks';
+ }
+
+ function renderCard(j) {
+ const name = j.name||j.id, s = j.schedule, st = j.state||{};
+ const dis = !j.enabled, run = st.lastStatus === 'running';
+ let sched = '';
+ if (s?.kind === 'cron') sched = cronH(s.expr);
+ else if (s?.kind === 'every' || s?.everyMs) sched = evH(s.everyMs);
+ else if (s?.expr) sched = cronH(s.expr);
+ const runCount = st.runCount != null ? st.runCount : '—';
+ return `
+
${D.esc(name)}
+
${D.esc(sched)}
+
+ Last: ${D.esc(st.lastRunAtMs ? fmtTs(st.lastRunAtMs) : 'never')}${st.lastStatus?' ('+D.esc(st.lastStatus)+')':''}
+ ${st.nextRunAtMs ? `Next: ${D.esc(fmtTs(st.nextRunAtMs))}` : ''}
+
+
▸ click for details
+
+ ⏱ Last run: ${D.esc(st.lastRunAtMs ? fmtTs(st.lastRunAtMs) : 'never')}
+ ⏭ Next run: ${D.esc(st.nextRunAtMs ? fmtTs(st.nextRunAtMs) : '—')}
+ 📊 Status: ${D.esc(st.lastStatus || 'unknown')}
+ 🔢 Run count: ${D.esc(String(runCount))}
+ ${j.target ? `🎯 Target: ${D.esc(j.target)}` : ''}
+ ${j.channel ? `📡 Channel: ${D.esc(j.channel)}` : ''}
+
+
+
+
+
+
`;
+ }
+
+ function render(jobs) {
+ const el = document.getElementById('panel-crons');
+ if (!el) return;
+ if (!jobs?.length) { el.innerHTML = '⏱No cron jobs
'; return; }
+
+ const groups = {};
+ for (const j of jobs) { const c = cat(j); (groups[c] = groups[c] || []).push(j); }
+
+ let h = '';
+ for (const [c, cj] of Object.entries(groups)) {
+ cj.sort((a,b) => (a.state?.lastStatus==='running'?-1:0)-(b.state?.lastStatus==='running'?-1:0) || (a.state?.nextRunAtMs||Infinity)-(b.state?.nextRunAtMs||Infinity));
+ h += `
${c} ${cj.length}
`;
+ h += cj.map(renderCard).join('');
+ }
+ h += '
';
+ el.innerHTML = h;
+ renderUpcoming(jobs);
+ }
+
+ function renderUpcoming(jobs) {
+ const el = document.getElementById('panel-crons-upcoming'); if (!el) return;
+ // Exclude heartbeats from upcoming on overview
+ const up = jobs
+ .filter(j => j.enabled && j.state?.nextRunAtMs && !isHeartbeat(j))
+ .sort((a,b) => a.state.nextRunAtMs - b.state.nextRunAtMs)
+ .slice(0,5);
+ if (!up.length) { el.innerHTML = 'No upcoming crons
'; return; }
+ el.innerHTML = up.map(j => `${D.esc(j.name||j.id)}
${D.esc(fmtTs(j.state.nextRunAtMs))}
`).join('');
+ if (up[0]) {
+ const d = up[0].state.nextRunAtMs - Date.now();
+ if (d > 0) { const m = (d/6e4)|0; window._updateOverviewMetrics?.({ nextCron: m >= 60 ? `${(m/60)|0}h ${m%60}m` : `${m}m` }); }
+ }
+ }
+
+ async function refresh(gw) {
+ let jobs = null;
+ if (gw?.authenticated) try { const r = await gw.rpc('cron.list', {}); jobs = r?.jobs || r?.items || (Array.isArray(r) ? r : null); } catch {}
+ if (!jobs) { const d = await D.fetchApi('crons'); jobs = d?.jobs || []; }
+ render(jobs || []);
+ }
+
+ let _refreshInterval = null;
+ let _lastUpdated = null;
+
+ function updateTimestamp() {
+ const el = document.getElementById('panel-crons');
+ if (!el) return;
+ let ts = el.querySelector('.panel-last-updated');
+ if (!ts) { ts = document.createElement('div'); ts.className = 'panel-last-updated'; el.appendChild(ts); }
+ if (_lastUpdated) {
+ const s = ((Date.now() - _lastUpdated) / 1000) | 0;
+ ts.textContent = `last updated: ${s}s ago`;
+ }
+ }
+
+ const _origRefresh = refresh;
+ async function autoRefresh(gw) {
+ await _origRefresh(gw);
+ _lastUpdated = Date.now();
+ updateTimestamp();
+ }
+
+ function startAutoRefresh(gw) {
+ stopAutoRefresh();
+ _refreshInterval = setInterval(() => autoRefresh(gw), 60000);
+ setInterval(updateTimestamp, 10000);
+ }
+ function stopAutoRefresh() { if (_refreshInterval) { clearInterval(_refreshInterval); _refreshInterval = null; } }
+
+ async function initPanel(gw) {
+ await autoRefresh(gw);
+ startAutoRefresh(gw);
+ }
+
+ D.registerPanel('crons', { init: initPanel, refresh: autoRefresh, stopAutoRefresh });
+})();
diff --git a/bates-core/plugins/dashboard/static/js/panel-delegations.js b/bates-core/plugins/dashboard/static/js/panel-delegations.js
new file mode 100644
index 0000000..2020564
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-delegations.js
@@ -0,0 +1,122 @@
+/**
+ * Claude Code Delegations Panel
+ * Shows running and recent Claude Code delegations with status tracking.
+ */
+(function () {
+ const D = window.Dashboard;
+ let delegations = [];
+ let fastRefreshInterval = null;
+
+ function statusBadge(status) {
+ const cls = {
+ running: "agent-status-running",
+ completed: "agent-status-completed",
+ failed: "agent-status-failed",
+ };
+ const labels = {
+ running: "\u25CF Running",
+ completed: "\u2713 Done",
+ failed: "\u2717 Failed",
+ };
+ return '' + (labels[status] || status) + "";
+ }
+
+ function formatDuration(ms) {
+ if (!ms) return "";
+ var s = Math.floor(ms / 1000);
+ if (s < 60) return s + "s";
+ var m = Math.floor(s / 60);
+ if (m < 60) return m + "m " + (s % 60) + "s";
+ var h = Math.floor(m / 60);
+ return h + "h " + (m % 60) + "m";
+ }
+
+ function renderCard(d) {
+ var elapsed = d.durationMs || (Date.now() - d.startedAt);
+ var duration = formatDuration(elapsed);
+ var started = D.timeAgo(new Date(d.startedAt).toISOString());
+ var desc = (d.description || "").slice(0, 120);
+ if (d.description && d.description.length > 120) desc += "...";
+ var promptName = (d.promptPath || "").split("/").pop() || "";
+ var logName = (d.logPath || "").split("/").pop() || "";
+ var isRunning = d.status === "running";
+
+ return '' +
+ '
' + (isRunning ? "\u{1F4BB}" : d.status === "completed" ? "\u2705" : "\u274C") + "
" +
+ '
' +
+ '
' + D.esc(d.name) + "
" +
+ '
' + D.esc(started) +
+ (duration ? " \u00B7 " + duration : "") +
+ (d.exitCode !== undefined && d.exitCode !== null ? " \u00B7 exit " + d.exitCode : "") +
+ "
" +
+ (desc ? '
' + D.esc(desc) + "
" : "") +
+ '
' +
+ (promptName ? '\u{1F4C4} ' + D.esc(promptName) + "" : "") +
+ (logName ? '\u{1F4CB} ' + D.esc(logName) + "" : "") +
+ "
" +
+ "
" +
+ statusBadge(d.status) +
+ "
";
+ }
+
+ function render() {
+ var el = document.getElementById("panel-delegations");
+ if (!el) return;
+
+ if (delegations.length === 0) {
+ el.innerHTML = '\u{1F4BB}No Claude Code delegations
';
+ manageFastRefresh(false);
+ return;
+ }
+
+ var running = delegations.filter(function (d) { return d.status === "running"; });
+ var completed = delegations.filter(function (d) { return d.status === "completed"; }).slice(0, 10);
+ var failed = delegations.filter(function (d) { return d.status === "failed"; }).slice(0, 5);
+
+ var html = '';
+ if (running.length > 0) {
+ html += '';
+ html += running.map(renderCard).join("");
+ }
+ if (completed.length > 0) {
+ html += (running.length > 0 ? '' : "");
+ html += completed.map(renderCard).join("");
+ }
+ if (failed.length > 0) {
+ html += '';
+ html += failed.map(renderCard).join("");
+ }
+ html += "
";
+ el.innerHTML = html;
+
+ manageFastRefresh(running.length > 0);
+ }
+
+ function manageFastRefresh(hasRunning) {
+ if (hasRunning && !fastRefreshInterval) {
+ fastRefreshInterval = setInterval(refresh, 5000);
+ } else if (!hasRunning && fastRefreshInterval) {
+ clearInterval(fastRefreshInterval);
+ fastRefreshInterval = null;
+ }
+ }
+
+ async function refresh() {
+ try {
+ var data = await D.fetchApi("delegations");
+ if (data && Array.isArray(data.delegations)) {
+ delegations = data.delegations;
+ }
+ } catch (e) {
+ // Keep existing data
+ }
+ render();
+ }
+
+ async function init() {
+ render();
+ await refresh();
+ }
+
+ D.registerPanel("delegations", { init: init, refresh: refresh });
+})();
diff --git a/bates-core/plugins/dashboard/static/js/panel-files.js b/bates-core/plugins/dashboard/static/js/panel-files.js
new file mode 100644
index 0000000..055753c
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-files.js
@@ -0,0 +1,163 @@
+/**
+ * File Explorer Panel
+ * Shows recently modified files in the workspace
+ */
+(function () {
+ const D = window.Dashboard;
+
+ function fileIcon(name) {
+ if (name.endsWith(".md")) return "📄";
+ if (name.endsWith(".json")) return "{";
+ if (name.endsWith(".sh")) return "⚙";
+ if (name.endsWith(".ts") || name.endsWith(".js")) return "✎";
+ if (name.endsWith(".py")) return "🐍";
+ if (name.endsWith(".pptx")) return "📊";
+ if (name.endsWith(".html") || name.endsWith(".css")) return "🌐";
+ return "📄";
+ }
+
+ function render(files) {
+ const el = document.getElementById("panel-files");
+ if (!el) return;
+
+ if (!files || files.length === 0) {
+ el.innerHTML = '📁No recent files
';
+ return;
+ }
+
+ let html = '';
+ for (const file of files) {
+ const dir = file.path.includes("/") ? file.path.substring(0, file.path.lastIndexOf("/")) : "";
+ // Configure your OneDrive base URL here (tenant-my.sharepoint.com/personal/user_tenant_com/...)
+ const oneDriveBase = 'https://TENANT-my.sharepoint.com/personal/USER_TENANT_COM/_layouts/15/onedrive.aspx?id=/personal/USER_TENANT_COM/Documents/';
+ const isDraft = file.path && file.path.startsWith('drafts/');
+ const webUrl = file.webUrl || (isDraft ? oneDriveBase + encodeURIComponent(file.path) : '');
+ const nameHtml = webUrl
+ ? `
${D.esc(file.name)}`
+ : `
${D.esc(file.name)}`;
+ html += `
+
+
${fileIcon(file.name)}
+
+
${nameHtml}
+ ${dir ? `
${D.esc(dir)}
` : ""}
+
+
+
`;
+ }
+ html += "
";
+ el.innerHTML = html;
+ }
+
+ const SHOW_EXTS = new Set(['.docx','.xlsx','.pptx','.pdf','.md','.html','.png','.jpg','.jpeg','.txt','.gif','.webp','.csv']);
+
+ function isUserFile(name) {
+ const dot = name.lastIndexOf('.');
+ if (dot < 0) return false;
+ return SHOW_EXTS.has(name.substring(dot).toLowerCase());
+ }
+
+ async function refresh() {
+ const files = await D.fetchApi("files");
+ const all = Array.isArray(files) ? files : [];
+ render(all.filter(f => isUserFile(f.name || '')));
+ }
+
+ let _refreshInterval = null;
+ let _lastUpdated = null;
+
+ function updateTimestamp() {
+ const el = document.getElementById("panel-files");
+ if (!el) return;
+ let ts = el.querySelector('.panel-last-updated');
+ if (!ts) { ts = document.createElement('div'); ts.className = 'panel-last-updated'; el.appendChild(ts); }
+ if (_lastUpdated) {
+ const s = ((Date.now() - _lastUpdated) / 1000) | 0;
+ ts.textContent = `last updated: ${s}s ago`;
+ }
+ }
+
+ const _origRefresh = refresh;
+ async function autoRefresh() {
+ await _origRefresh();
+ _lastUpdated = Date.now();
+ updateTimestamp();
+ }
+
+ function startAutoRefresh() {
+ stopAutoRefresh();
+ _refreshInterval = setInterval(autoRefresh, 120000);
+ setInterval(updateTimestamp, 10000);
+ }
+ function stopAutoRefresh() { if (_refreshInterval) { clearInterval(_refreshInterval); _refreshInterval = null; } }
+
+ async function initPanel() {
+ await autoRefresh();
+ startAutoRefresh();
+ }
+
+ window._showFileContent = async function(path) {
+ const ov = document.getElementById('soul-modal-overlay');
+ if (!ov) return;
+ const titleEl = document.getElementById('soul-modal-title');
+ const bodyEl = document.getElementById('soul-modal-body');
+ titleEl.textContent = '📄 ' + path;
+ const absPath = '~/.openclaw/workspace/' + path;
+ const ext = path.split('.').pop().toLowerCase();
+ const typeMap = {md:'Markdown',json:'JSON',ts:'TypeScript',js:'JavaScript',py:'Python',sh:'Shell',html:'HTML',css:'CSS',txt:'Text',csv:'CSV',yaml:'YAML',yml:'YAML'};
+ const fileType = typeMap[ext] || ext.toUpperCase();
+
+ // Try to fetch file content from the API
+ let contentHtml = '';
+ try {
+ const resp = await D.fetchApi('file?path=' + encodeURIComponent(path));
+ if (resp && !resp.error) {
+ const text = resp.content || '';
+ if (text && text !== 'Not found') {
+ contentHtml = `
+
+
Contents
+
${Dashboard.esc(text)}
+
+ `;
+ }
+ }
+ } catch(e) {}
+
+ if (!contentHtml) {
+ contentHtml = `
+
+
+ 📂 Full path:
+
+
${Dashboard.esc(absPath)}
+
+
`;
+ }
+
+ bodyEl.innerHTML = `
+
+
+
File Details
+
+
Path: ${Dashboard.esc(path)}
+
Type: ${Dashboard.esc(fileType)}
+
+
+ ${contentHtml}
+
`;
+ ov.classList.add('visible');
+ };
+
+ D.registerPanel("files", {
+ init: initPanel,
+ refresh: autoRefresh,
+ stopAutoRefresh,
+ });
+})();
diff --git a/bates-core/plugins/dashboard/static/js/panel-integrations.js b/bates-core/plugins/dashboard/static/js/panel-integrations.js
new file mode 100644
index 0000000..07671b9
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-integrations.js
@@ -0,0 +1,87 @@
+/**
+ * Integrations Panel — MCP Servers & External Services (Live Data Only)
+ */
+(function () {
+ const D = window.Dashboard;
+
+ function render(healthData) {
+ const el = document.getElementById('panel-integrations');
+ if (!el) return;
+
+ if (!healthData || !healthData.servers || !healthData.servers.length) {
+ el.innerHTML = '⏳ Checking MCP server health...
';
+ return;
+ }
+
+ let h = 'MCP Servers (Live Health)
';
+ h += '';
+
+ const servers = healthData.servers.sort((a, b) => {
+ if (a.healthy !== b.healthy) return a.healthy ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
+
+ for (const s of servers) {
+ const statusColor = s.healthy ? 'var(--green)' : 'var(--red, #ef4444)';
+ const statusText = s.healthy
+ ? `✓ ${s.tools} tool${s.tools !== 1 ? 's' : ''} · ${s.responseTime}s`
+ : '✗ Unhealthy';
+ h += `
+
+
+
${D.esc(s.name)}
+
${statusText}
+
+
`;
+ }
+ h += '
';
+
+ const healthy = servers.filter(s => s.healthy).length;
+ h += `${healthy}/${servers.length} servers healthy
`;
+
+ el.innerHTML = h;
+ }
+
+ async function refresh() {
+ let healthData = null;
+ try {
+ healthData = await D.fetchApi('integrations/health');
+ } catch {}
+ render(healthData);
+ }
+
+ let _refreshInterval = null;
+ let _lastUpdated = null;
+
+ function updateTimestamp() {
+ const el = document.getElementById('panel-integrations');
+ if (!el) return;
+ let ts = el.querySelector('.panel-last-updated');
+ if (!ts) { ts = document.createElement('div'); ts.className = 'panel-last-updated'; el.appendChild(ts); }
+ if (_lastUpdated) {
+ const s = ((Date.now() - _lastUpdated) / 1000) | 0;
+ ts.textContent = `last updated: ${s}s ago`;
+ }
+ }
+
+ const _origRefresh = refresh;
+ async function autoRefresh() {
+ await _origRefresh();
+ _lastUpdated = Date.now();
+ updateTimestamp();
+ }
+
+ function startAutoRefresh() {
+ stopAutoRefresh();
+ _refreshInterval = setInterval(autoRefresh, 120000);
+ setInterval(updateTimestamp, 10000);
+ }
+ function stopAutoRefresh() { if (_refreshInterval) { clearInterval(_refreshInterval); _refreshInterval = null; } }
+
+ async function initPanel() {
+ await autoRefresh();
+ startAutoRefresh();
+ }
+
+ D.registerPanel('integrations', { init: initPanel, refresh: autoRefresh, stopAutoRefresh });
+})();
diff --git a/bates-core/plugins/dashboard/static/js/panel-memory.js b/bates-core/plugins/dashboard/static/js/panel-memory.js
new file mode 100644
index 0000000..2548f16
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-memory.js
@@ -0,0 +1,177 @@
+/**
+ * Live Memory Feed Panel
+ * Shows observation data + real-time agent events
+ */
+(function () {
+ const D = window.Dashboard;
+ let entries = [];
+ let activeFilter = null;
+ const MAX_ENTRIES = 100;
+
+ const CATEGORIES = ["goal", "fact", "preference", "deadline", "decision", "contact", "pattern", "agent"];
+
+ function parseObservations(data) {
+ const items = [];
+ if (!data) return items;
+
+ for (const [filename, content] of Object.entries(data)) {
+ if (filename.endsWith(".json")) {
+ // Parse JSON observations (like last-checkin.json)
+ try {
+ const obj = JSON.parse(content);
+ if (obj.last_run) {
+ items.push({
+ timestamp: obj.last_run,
+ tag: "agent",
+ content: `Check-in: ${obj.items_reported_today || 0} items reported, ${obj.skipped_runs || 0} skipped`,
+ });
+ }
+ } catch {}
+ continue;
+ }
+
+ // Parse markdown observations
+ const category = filename.replace(".md", "").replace("file-index", "fact");
+ if (!CATEGORIES.includes(category) && category !== "file-index") continue;
+
+ const lines = content.split("\n");
+ let currentEntry = null;
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("|") || trimmed.startsWith("---")) continue;
+
+ // Date-prefixed entry: "- 2026-02-07: Something"
+ const dateMatch = trimmed.match(/^-\s*(\d{4}-\d{2}-\d{2}):\s*(.+)/);
+ if (dateMatch) {
+ items.push({
+ timestamp: dateMatch[1] + "T12:00:00Z",
+ tag: category,
+ content: dateMatch[2],
+ });
+ continue;
+ }
+
+ // Bullet entry without date
+ const bulletMatch = trimmed.match(/^[-*]\s+(.+)/);
+ if (bulletMatch) {
+ items.push({
+ timestamp: null,
+ tag: category,
+ content: bulletMatch[1],
+ });
+ }
+ }
+ }
+
+ return items;
+ }
+
+ function addAgentEvent(data) {
+ const content = data.text || data.message || data.delta || JSON.stringify(data).slice(0, 200);
+ if (!content || content === "{}") return;
+
+ entries.unshift({
+ timestamp: new Date().toISOString(),
+ tag: "agent",
+ content: String(content).slice(0, 300),
+ });
+
+ if (entries.length > MAX_ENTRIES) {
+ entries = entries.slice(0, MAX_ENTRIES);
+ }
+
+ render();
+ }
+
+ function render() {
+ const el = document.getElementById("panel-memory");
+ if (!el) return;
+
+ const filtered = activeFilter ? entries.filter((e) => e.tag === activeFilter) : entries;
+
+ if (filtered.length === 0) {
+ el.innerHTML = '⚛No observations yet
';
+ return;
+ }
+
+ let html = '';
+ for (const entry of filtered) {
+ const ts = entry.timestamp
+ ? new Date(entry.timestamp).toLocaleDateString("en-GB", { month: "short", day: "numeric" })
+ : "";
+ html += `
+
+ ${D.esc(ts)}
+ ${D.esc(entry.tag)}
+ ${D.esc(entry.content)}
+
`;
+ }
+ html += "
";
+ el.innerHTML = html;
+ }
+
+ function setupFilters() {
+ const bar = document.getElementById("memory-filters");
+ if (!bar) return;
+
+ let html = ``;
+ for (const cat of CATEGORIES) {
+ html += ``;
+ }
+ bar.innerHTML = html;
+
+ bar.addEventListener("click", (e) => {
+ const btn = e.target.closest(".filter-btn");
+ if (!btn) return;
+ const filter = btn.dataset.filter;
+ activeFilter = filter === "all" ? null : filter;
+ bar.querySelectorAll(".filter-btn").forEach((b) => b.classList.remove("active"));
+ btn.classList.add("active");
+ render();
+ });
+ }
+
+ async function refresh() {
+ const data = await D.fetchApi("observations");
+ if (data && !data.error) {
+ const parsed = parseObservations(data);
+ // Merge new observations, keeping agent events from WebSocket
+ const agentEntries = entries.filter((e) => e.tag === "agent" && e.timestamp);
+ entries = [...agentEntries, ...parsed];
+ // Sort: dated entries by date desc, undated at the end
+ entries.sort((a, b) => {
+ if (!a.timestamp && !b.timestamp) return 0;
+ if (!a.timestamp) return 1;
+ if (!b.timestamp) return -1;
+ return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
+ });
+ if (entries.length > MAX_ENTRIES) entries = entries.slice(0, MAX_ENTRIES);
+ }
+ render();
+ }
+
+ async function init(gw) {
+ setupFilters();
+ await refresh();
+
+ // Subscribe to real-time agent events
+ if (gw) {
+ gw.subscribe("agent", (data) => {
+ if (data.event === "agent.assistant" || data.type === "assistant") {
+ addAgentEvent(data);
+ }
+ });
+ gw.subscribe("*", (data) => {
+ if (data.event && data.event.includes("memory")) {
+ addAgentEvent(data);
+ }
+ });
+ }
+ }
+
+ D.registerPanel("memory", {
+ init,
+ refresh,
+ });
+})();
diff --git a/bates-core/plugins/dashboard/static/js/panel-rollout.js b/bates-core/plugins/dashboard/static/js/panel-rollout.js
new file mode 100644
index 0000000..9989d21
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-rollout.js
@@ -0,0 +1,112 @@
+/**
+ * Bates Rollout Panel — Agent deployment status by layer
+ * Fetches from gateway API
+ */
+(function () {
+ const D = window.Dashboard;
+
+ const LAYERS = [
+ { name: 'Layer 1 — COO', agents: [{ name: 'Bates', role: 'Chief Operating Officer' }] },
+ { name: 'Layer 2 — Deputies', agents: [
+ { name: 'Conrad', role: 'fDesk Deputy' },
+ { name: 'Soren', role: 'SynapseLayer Deputy' },
+ { name: 'Amara', role: 'Escola Caravela Deputy' },
+ { name: 'Jules', role: 'Personal Deputy' },
+ { name: 'Dash', role: 'DevOps Deputy' },
+ ]},
+ { name: 'Layer 3 — Specialists', agents: [
+ { name: 'Mercer', role: 'Finance Specialist' },
+ { name: 'Kira', role: 'Content Specialist' },
+ { name: 'Nova', role: 'Research Specialist' },
+ { name: 'Paige', role: 'Documentation Specialist' },
+ { name: 'Quinn', role: 'QA Specialist' },
+ { name: 'Archer', role: 'Architecture Specialist' },
+ ]},
+ ];
+
+ const ALL_AGENTS = LAYERS.flatMap(l => l.agents);
+
+ function findAgent(apiAgents, name) {
+ if (!apiAgents) return null;
+ return apiAgents.find(a => a.name && a.name.toLowerCase() === name.toLowerCase());
+ }
+
+ function render(apiAgents) {
+ const el = document.getElementById('panel-rollout');
+ if (!el) return;
+
+ const deployed = ALL_AGENTS.filter(a => findAgent(apiAgents, a.name)).length;
+ const total = ALL_AGENTS.length;
+ const pct = Math.round((deployed / total) * 100);
+
+ let html = '';
+
+ // Progress bar
+ html += ``;
+
+ // Layers
+ for (const layer of LAYERS) {
+ html += `
+
${D.esc(layer.name)} ${layer.agents.filter(a => findAgent(apiAgents, a.name)).length}/${layer.agents.length}
+
`;
+
+ for (const agentDef of layer.agents) {
+ const data = findAgent(apiAgents, agentDef.name);
+ const exists = !!data;
+ const model = data && data.model ? data.model : '—';
+ const workspace = data && data.workspace !== undefined ? (data.workspace ? '✓' : '✗') : (exists ? '✓' : '✗');
+ const wsClass = workspace === '✓' ? 'ok' : 'error';
+ const heartbeat = data && data.heartbeat ? data.heartbeat : null;
+ const hbActive = heartbeat && (heartbeat.active || heartbeat.enabled || heartbeat.cron);
+ const hbTime = data && (data.lastHeartbeat || data.last_heartbeat || (heartbeat && heartbeat.last)) ? D.timeAgo(data.lastHeartbeat || data.last_heartbeat || heartbeat.last) : '—';
+ const statusIcon = exists ? '☑' : '☐';
+ const statusClass = exists ? 'rollout-deployed' : 'rollout-pending';
+
+ // Model badge
+ let modelClass = 'other';
+ const ml = model.toLowerCase();
+ if (ml.includes('opus')) modelClass = 'opus';
+ else if (ml.includes('sonnet')) modelClass = 'sonnet';
+ else if (ml.includes('gemini')) modelClass = 'gemini';
+
+ html += `
+
${statusIcon}
+
+
${D.esc(agentDef.name)} ${D.esc(data && data.role ? data.role : agentDef.role)}
+
+ ${D.esc(model)}
+
+ ${hbActive ? '⏱ Active' : '⏱ Inactive'}
+ Last: ${D.esc(hbTime)}
+
+
+
`;
+ }
+
+ html += '
';
+ }
+
+ el.innerHTML = html;
+ }
+
+ async function refresh() {
+ try {
+ const data = await D.fetchApi('agents');
+ if (data) {
+ const agents = Array.isArray(data) ? data : (data && data.agents ? data.agents : []);
+ render(agents);
+ return;
+ }
+ } catch {}
+ const el = document.getElementById('panel-rollout');
+ if (el) el.innerHTML = '🚀No data available
Could not reach gateway API
';
+ }
+
+ D.registerPanel('rollout', { init: refresh, refresh });
+})();
diff --git a/bates-core/plugins/dashboard/static/js/panel-settings.js b/bates-core/plugins/dashboard/static/js/panel-settings.js
new file mode 100644
index 0000000..d31ba72
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-settings.js
@@ -0,0 +1,536 @@
+/**
+ * Settings Panel — Sub-tabbed, collapsible, with whitelist editor
+ * Tabs: Overview | M365 Safety | Whitelists
+ */
+(function () {
+ const D = window.Dashboard;
+ let _data = null;
+ let _whitelist = null;
+ let _activeTab = 'overview';
+
+ function authHeaders(extra) {
+ const h = {};
+ const token = window.__GATEWAY_CONFIG?.token;
+ if (token) h['Authorization'] = 'Bearer ' + token;
+ return Object.assign(h, extra || {});
+ }
+
+ // ── Collapsible card ──
+ function collapseCard(id, title, contentHtml, opts) {
+ const open = opts?.open !== false;
+ const cls = opts?.cls || '';
+ return `
+
+ ${D.esc(title)}
+
+
+
${contentHtml}
+
`;
+ }
+
+ function kvRows(items) {
+ return items.map(([k, v]) =>
+ `${D.esc(k)}${D.esc(String(v))}
`
+ ).join('');
+ }
+
+ // ── Tab: Overview ──
+ function renderOverview(d) {
+ return '' +
+ collapseCard('model', 'Model', kvRows([
+ ['Primary', d.default_model || '\u2014'],
+ ['Fallbacks', (d.model_fallbacks || []).join(', ') || '\u2014'],
+ ])) +
+ collapseCard('fleet', 'Fleet', kvRows([
+ ['Agents', d.num_agents || '\u2014'],
+ ['Cron Jobs', d.num_cron_jobs || '\u2014'],
+ ['Enabled', d.num_cron_enabled || '\u2014'],
+ ])) +
+ collapseCard('session', 'Session', kvRows([
+ ['Reset Mode', d.session_reset_mode || '\u2014'],
+ ['Idle Timeout', (d.session_idle_minutes || '?') + 'm'],
+ ['Gateway Port', d.gateway_port || '\u2014'],
+ ])) +
+ collapseCard('compaction', 'Compaction', kvRows([
+ ['Mode', d.compaction_mode || '\u2014'],
+ ['Reserve Tokens', d.compaction_reserve_tokens || '\u2014'],
+ ['Max History', d.compaction_max_history || '\u2014'],
+ ])) +
+ '
';
+ }
+
+ // ── Tab: M365 Safety ──
+ function renderSafety(m365) {
+ if (!m365) return 'M365 safety data unavailable
';
+ const isOverride = m365.override_active;
+
+ let html = collapseCard('safety-status', 'Safety Gateway Status', (() => {
+ const statusClass = isOverride ? 'safety-status-danger' : 'safety-status-ok';
+ const statusText = isOverride ? 'ALL PROTECTION DISABLED' : 'Active';
+ const statusIcon = isOverride ? '\u26A0\uFE0F' : '\u2705';
+ let inner = `
+ Enforcement
+ ${statusIcon} ${statusText}
+
`;
+
+ if (!isOverride) {
+ inner += `
+
+
Removes email whitelist, calendar protection, and Graph API interception
+
`;
+ } else {
+ inner += `
+
\uD83D\uDEA8
+
+ Safety Override Active
+ The agent has unrestricted access to Microsoft 365.
+
+
+ `;
+ }
+ return inner;
+ })(), { cls: isOverride ? 'safety-card-danger' : '' });
+
+ return html;
+ }
+
+ // ── Tab: Whitelists ──
+ function renderWhitelists(wl) {
+ if (!wl) return 'Loading whitelist...
';
+ if (wl.error) return `Whitelist error: ${D.esc(wl.error)}
`;
+
+ let html = '';
+
+ // Email
+ html += collapseCard('wl-email', 'Email Recipients', (() => {
+ let inner = '';
+ inner += '
Allowed Domains
';
+ inner += renderTagList('email-domains', wl.email?.allowed_domains || [], 'e.g. example.com');
+ inner += '
Allowed Addresses
';
+ inner += renderTagList('email-addrs', wl.email?.allowed_addresses || [], 'e.g. user@example.com');
+ inner += kvRows([
+ ['Max Recipients', wl.email?.max_recipients || 10],
+ ['Block Distribution Lists', wl.email?.block_distribution_lists ? 'Yes' : 'No'],
+ ]);
+ return inner + '
';
+ })());
+
+ // Calendar
+ html += collapseCard('wl-calendar', 'Calendar Attendees', (() => {
+ let inner = '';
+ inner += '
Allowed Domains
';
+ inner += renderTagList('cal-domains', wl.calendar?.allowed_domains || [], 'e.g. example.com');
+ inner += '
Allowed Addresses
';
+ inner += renderTagList('cal-addrs', wl.calendar?.allowed_addresses || [], 'e.g. user@example.com');
+ inner += kvRows([
+ ['Allow No-Attendee Events', wl.calendar?.allow_no_attendee_events ? 'Yes' : 'No'],
+ ['Max Past Days', wl.calendar?.max_past_days || 0],
+ ]);
+ return inner + '
';
+ })());
+
+ // OneDrive
+ html += collapseCard('wl-onedrive', 'OneDrive', (() => {
+ let inner = '';
+ inner += '
Allowed Write Paths
';
+ inner += renderTagList('od-paths', wl.onedrive?.allowed_write_paths || [], 'e.g. /drafts/');
+ inner += kvRows([
+ ['Block External Sharing', wl.onedrive?.block_external_sharing ? 'Yes' : 'No'],
+ ['Block Delete', wl.onedrive?.block_delete ? 'Yes' : 'No'],
+ ]);
+ return inner + '
';
+ })(), { open: false });
+
+ // Rate Limits
+ html += collapseCard('wl-rates', 'Rate Limits', kvRows([
+ ['Global (per min)', wl.rate_limits?.global || 60],
+ ['Email Send (per min)', wl.rate_limits?.email_send || 5],
+ ['Calendar Create (per min)', wl.rate_limits?.calendar_create || 10],
+ ]), { open: false });
+
+ return html;
+ }
+
+ function renderTagList(id, items, placeholder) {
+ let html = `';
+ return html;
+ }
+
+ // ── Whitelist mutation ──
+ function getWhitelistPath(listId) {
+ const map = {
+ 'email-domains': ['email', 'allowed_domains'],
+ 'email-addrs': ['email', 'allowed_addresses'],
+ 'cal-domains': ['calendar', 'allowed_domains'],
+ 'cal-addrs': ['calendar', 'allowed_addresses'],
+ 'od-paths': ['onedrive', 'allowed_write_paths'],
+ };
+ return map[listId] || null;
+ }
+
+ async function addToWhitelist(listId, value) {
+ const path = getWhitelistPath(listId);
+ if (!path) return;
+ const res = await fetch('/dashboard/api/settings/whitelist', {
+ method: 'POST',
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ action: 'add', section: path[0], field: path[1], value }),
+ });
+ const r = await res.json();
+ if (r.success) { _whitelist = null; await loadAndRenderTab(); }
+ }
+
+ async function removeFromWhitelist(listId, value) {
+ const path = getWhitelistPath(listId);
+ if (!path) return;
+ const res = await fetch('/dashboard/api/settings/whitelist', {
+ method: 'POST',
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ action: 'remove', section: path[0], field: path[1], value }),
+ });
+ const r = await res.json();
+ if (r.success) { _whitelist = null; await loadAndRenderTab(); }
+ }
+
+ // ── Event delegation ──
+ function attachHandlers() {
+ const el = document.getElementById('panel-settings');
+ if (!el) return;
+
+ el.addEventListener('click', async (e) => {
+ const t = e.target;
+
+ // Sub-tab navigation
+ if (t.closest('.s-tab')) {
+ const tab = t.closest('.s-tab').dataset.tab;
+ if (tab) { _activeTab = tab; await loadAndRenderTab(); }
+ return;
+ }
+
+ // Tag remove
+ if (t.classList.contains('wl-tag-rm')) {
+ const listId = t.dataset.list;
+ const val = t.dataset.val;
+ if (listId && val) await removeFromWhitelist(listId, val);
+ return;
+ }
+
+ // Tag add
+ if (t.classList.contains('wl-add-btn')) {
+ const listId = t.dataset.list;
+ const input = document.getElementById('wl-input-' + listId);
+ const val = input?.value?.trim();
+ if (listId && val) { await addToWhitelist(listId, val); }
+ return;
+ }
+
+ // Task provider: remove
+ if (t.dataset.tpRemove) {
+ if (confirm('Remove task source "' + t.dataset.tpRemove + '"?')) {
+ await disconnectTaskProvider(t.dataset.tpRemove);
+ }
+ return;
+ }
+ // Task provider: add planner
+ if (t.dataset.tpAddPlanner) {
+ await connectTaskProvider('planner', t.dataset.tpAddPlanner, t.dataset.tpName);
+ return;
+ }
+ // Task provider: add todo
+ if (t.dataset.tpAddTodo) {
+ await connectTaskProvider('todo', t.dataset.tpAddTodo, t.dataset.tpName);
+ return;
+ }
+
+ // Safety disable
+ if (t.id === 'm365-safety-disable-btn' || t.closest('#m365-safety-disable-btn')) {
+ showConfirmDialog();
+ return;
+ }
+
+ // Safety restore
+ if (t.id === 'm365-safety-restore-btn' || t.closest('#m365-safety-restore-btn')) {
+ try {
+ const res = await fetch('/dashboard/api/settings/m365-safety', {
+ method: 'POST',
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ enforcement: 'active' }),
+ });
+ const result = await res.json();
+ if (result.success) {
+ showRestartBanner('Safety protection restored. Gateway restart required.');
+ _data = null; await loadAndRenderTab();
+ }
+ } catch (e) { console.error('Restore failed:', e); }
+ return;
+ }
+ });
+
+ // Enter key on whitelist inputs
+ el.addEventListener('keydown', async (e) => {
+ if (e.key === 'Enter' && e.target.classList.contains('wl-add-input')) {
+ const input = e.target;
+ const listId = input.id.replace('wl-input-', '');
+ const val = input.value.trim();
+ if (listId && val) await addToWhitelist(listId, val);
+ }
+ });
+ }
+
+ // ── Confirm dialog (unchanged) ──
+ function showConfirmDialog() {
+ const existing = document.getElementById('safety-confirm-overlay');
+ if (existing) existing.remove();
+
+ const overlay = document.createElement('div');
+ overlay.id = 'safety-confirm-overlay';
+ overlay.className = 'safety-overlay';
+ overlay.innerHTML = `
+
+
+
Disable M365 Safety Protection?
+
+
This will completely disable all Microsoft 365 safety measures:
+
+ - \u274C Email recipient whitelist \u2014 agent can email anyone
+ - \u274C Calendar attendee protection \u2014 agent can invite anyone
+ - \u274C Graph API interception \u2014 agent gets unrestricted API access
+ - \u274C Audit logging \u2014 actions will not be logged
+
+
Only use this if the safety gateway is causing critical failures.
+
+
+
+
+
+
+
+
+
`;
+ document.body.appendChild(overlay);
+
+ document.getElementById('safety-confirm-check').addEventListener('change', (e) => {
+ document.getElementById('safety-confirm-btn').disabled = !e.target.checked;
+ });
+ document.getElementById('safety-cancel-btn').addEventListener('click', () => overlay.remove());
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
+
+ document.getElementById('safety-confirm-btn').addEventListener('click', async () => {
+ const btn = document.getElementById('safety-confirm-btn');
+ btn.disabled = true; btn.textContent = 'Disabling...';
+ try {
+ const res = await fetch('/dashboard/api/settings/m365-safety', {
+ method: 'POST',
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ enforcement: 'OVERRIDE_ALL_SAFETY' }),
+ });
+ const result = await res.json();
+ overlay.remove();
+ if (result.success) {
+ showRestartBanner('Safety protection DISABLED. Gateway restart required.');
+ _data = null; await loadAndRenderTab();
+ }
+ } catch (e) {
+ console.error(e);
+ btn.disabled = false; btn.textContent = 'Disable All Protection';
+ }
+ });
+ }
+
+ function showRestartBanner(message) {
+ const existing = document.getElementById('safety-restart-banner');
+ if (existing) existing.remove();
+ const banner = document.createElement('div');
+ banner.id = 'safety-restart-banner';
+ banner.className = 'safety-restart-banner';
+ banner.innerHTML = `${D.esc(message)}
+ `;
+ const panel = document.getElementById('panel-settings');
+ if (panel) panel.prepend(banner);
+ }
+
+ // ── Tab: Task Providers ──
+ let _providers = null;
+
+ function renderTaskProviders() {
+ if (!_providers) return 'Loading task providers...
';
+ if (_providers.error) return `Error: ${D.esc(_providers.error)}
`;
+
+ let html = '';
+
+ // Connected plans
+ html += collapseCard('tp-connected', 'Connected Task Sources', (() => {
+ const connected = _providers.connected || {};
+ if (!Object.keys(connected).length) return 'No task sources connected yet.
';
+ let inner = '';
+ for (const [key, plan] of Object.entries(connected)) {
+ const p = plan;
+ const icon = p.source === 'planner' ? '📋' : '✅';
+ const idLabel = p.source === 'planner' ? p.planId : (p.todoListId || '').slice(0, 20) + '...';
+ inner += `
+
+ ${icon}
+ ${D.esc(p.name)}
+ ${D.esc(key)} · ${D.esc(p.source)}
+
+
+
`;
+ }
+ return inner;
+ })());
+
+ // Available Planner plans
+ html += collapseCard('tp-planner', 'Available Planner Plans', (() => {
+ const plans = (_providers.plans || []).filter(p => !p.error);
+ if (!plans.length) return 'No Planner plans found or discovery failed.
';
+ const connected = _providers.connected || {};
+ const connectedIds = new Set(Object.values(connected).filter(p => p.source === 'planner').map(p => p.planId));
+ let inner = '';
+ for (const p of plans) {
+ const isConnected = connectedIds.has(p.id);
+ inner += `
+
+ 📋 ${D.esc(p.title)}
+ ${D.esc(p.id.slice(0, 12))}...
+
+ ${isConnected
+ ? '
Connected'
+ : `
`
+ }
+
`;
+ }
+ return inner;
+ })(), { open: false });
+
+ // Available To Do lists
+ html += collapseCard('tp-todo', 'Available To Do Lists', (() => {
+ const lists = (_providers.todoLists || []).filter(l => !l.error);
+ if (!lists.length) return 'No To Do lists found or discovery failed.
';
+ const connected = _providers.connected || {};
+ const connectedIds = new Set(Object.values(connected).filter(p => p.source === 'todo').map(p => p.todoListId));
+ let inner = '';
+ for (const l of lists) {
+ const isConnected = connectedIds.has(l.id);
+ inner += `
+
+ ✅ ${D.esc(l.displayName)}
+ ${l.wellknownListName ? `(${D.esc(l.wellknownListName)})` : ''}
+
+ ${isConnected
+ ? '
Connected'
+ : `
`
+ }
+
`;
+ }
+ return inner;
+ })(), { open: false });
+
+ return html;
+ }
+
+ async function connectTaskProvider(source, id, name) {
+ const key = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+ const body = { action: 'add', key, name, source };
+ if (source === 'planner') body.planId = id;
+ else body.todoListId = id;
+ try {
+ const res = await fetch('/dashboard/api/tasks/connect', {
+ method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify(body)
+ });
+ const r = await res.json();
+ if (r.success) { _providers = null; await loadAndRenderTab(); }
+ } catch (e) { console.error('Connect failed:', e); }
+ }
+
+ async function disconnectTaskProvider(key) {
+ try {
+ const res = await fetch('/dashboard/api/tasks/connect', {
+ method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ action: 'remove', key })
+ });
+ const r = await res.json();
+ if (r.success) { _providers = null; await loadAndRenderTab(); }
+ } catch (e) { console.error('Disconnect failed:', e); }
+ }
+
+ // ── Main render ──
+ function renderTabs() {
+ const tabs = [
+ { id: 'overview', label: 'Overview' },
+ { id: 'tasks', label: 'Task Providers' },
+ { id: 'safety', label: 'M365 Safety' },
+ { id: 'whitelists', label: 'Whitelists' },
+ ];
+ return '' +
+ tabs.map(t => ``).join('') +
+ '
';
+ }
+
+ async function loadAndRenderTab() {
+ const el = document.getElementById('panel-settings');
+ if (!el) return;
+
+ // Load settings if needed
+ if (!_data) {
+ try { _data = await D.fetchApi('settings'); } catch {}
+ }
+ if (!_data || _data.error) {
+ el.innerHTML = '⚙Settings unavailable
';
+ return;
+ }
+
+ let body = '';
+ if (_activeTab === 'overview') {
+ body = renderOverview(_data);
+ } else if (_activeTab === 'tasks') {
+ if (!_providers) {
+ try { _providers = await D.fetchApi('tasks/providers'); } catch {}
+ }
+ body = renderTaskProviders();
+ } else if (_activeTab === 'safety') {
+ body = renderSafety(_data.m365_safety);
+ } else if (_activeTab === 'whitelists') {
+ if (!_whitelist) {
+ try { _whitelist = await D.fetchApi('settings/whitelist'); } catch {}
+ }
+ body = renderWhitelists(_whitelist);
+ }
+
+ el.innerHTML = renderTabs() + body;
+ }
+
+ async function refresh() {
+ _data = null;
+ _whitelist = null;
+ await loadAndRenderTab();
+ }
+
+ function init() {
+ attachHandlers();
+ refresh();
+ }
+
+ D.registerPanel('settings', { init, refresh });
+})();
diff --git a/bates-core/plugins/dashboard/static/js/panel-standup.js b/bates-core/plugins/dashboard/static/js/panel-standup.js
new file mode 100644
index 0000000..6a4174d
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-standup.js
@@ -0,0 +1,120 @@
+/**
+ * Standup Panel — Conversation-style standup view with date navigation
+ */
+(function () {
+ const D = window.Dashboard;
+ let currentDate = new Date().toISOString().slice(0, 10);
+ let availableDates = [];
+
+ function getAvatar(name) {
+ const id = (name || '').toLowerCase();
+ const src = window.AGENT_AVATARS?.[id];
+ if (src) {
+ return `
`;
+ }
+ return '🤖';
+ }
+
+ function renderStandups(data) {
+ const el = document.getElementById('panel-standup');
+ if (!el) return;
+
+ const standups = data?.standups || [];
+ const dates = data?.dates || [];
+ availableDates = dates;
+
+ let h = `
+
📋 Daily Standup
+
+
+ ${D.esc(currentDate)}
+
+
+
+
+
+
`;
+
+ if (!standups.length) {
+ h += `
+
📋
+
No standup for ${D.esc(currentDate)}
+
Standups run at 08:30 via the daily-standup-compile cron job.
+
`;
+ el.innerHTML = h;
+ wireNav();
+ return;
+ }
+
+ h += `${standups.length} agent${standups.length !== 1 ? 's' : ''} reported
`;
+ h += '';
+ for (const msg of standups) {
+ const name = msg.name || msg.agent || 'Unknown';
+ const role = msg.role || '';
+ const text = msg.message || msg.content || msg.text || '';
+ const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '';
+
+ // Format standup text — parse **Yesterday:** **Today:** **Blockers:** sections
+ const formatted = formatStandupText(text);
+
+ h += `
+
${getAvatar(name)}
+
+
+ ${D.esc(name)}
+ ${D.esc(role)}
+ ${time ? `${time}` : ''}
+
+
${formatted}
+
+
`;
+ }
+ h += '
';
+ el.innerHTML = h;
+ wireNav();
+ }
+
+ function formatStandupText(text) {
+ // Parse standup format and add structure
+ return D.esc(text)
+ .replace(/\*\*(Yesterday|Today|Blockers?|Flags?|Flag):\*\*/gi, '$1:')
+ .replace(/\n/g, '
');
+ }
+
+ function wireNav() {
+ document.getElementById('standup-prev')?.addEventListener('click', () => navigateDate(-1));
+ document.getElementById('standup-next')?.addEventListener('click', () => navigateDate(1));
+ document.getElementById('standup-date-picker')?.addEventListener('change', (e) => {
+ currentDate = e.target.value;
+ refresh();
+ });
+ }
+
+ function navigateDate(delta) {
+ const idx = availableDates.indexOf(currentDate);
+ if (idx < 0) {
+ // Find nearest date
+ const d = new Date(currentDate);
+ d.setDate(d.getDate() + delta);
+ currentDate = d.toISOString().slice(0, 10);
+ } else {
+ const newIdx = idx - delta; // dates are reverse sorted
+ if (newIdx >= 0 && newIdx < availableDates.length) {
+ currentDate = availableDates[newIdx];
+ }
+ }
+ refresh();
+ }
+
+ async function refresh() {
+ try {
+ const data = await D.fetchApi(`standups?date=${currentDate}`);
+ if (data) { renderStandups(data); return; }
+ } catch {}
+ renderStandups({ standups: [], dates: availableDates });
+ }
+
+ D.registerPanel('standup', { init: refresh, refresh });
+})();
diff --git a/bates-core/plugins/dashboard/static/js/panel-status.js b/bates-core/plugins/dashboard/static/js/panel-status.js
new file mode 100644
index 0000000..2bc19e1
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-status.js
@@ -0,0 +1,93 @@
+/**
+ * System Status Panel
+ * Shows gateway, telegram, MCP, disk usage from health.json
+ */
+(function () {
+ const D = window.Dashboard;
+
+ function render(health) {
+ const el = document.getElementById("panel-status");
+ if (!el) return;
+
+ if (!health || health.error) {
+ el.innerHTML = '⚠Health data unavailable
';
+ return;
+ }
+
+ const services = health.services || {};
+ const gwStatus = services.openclaw_gateway || "unknown";
+ const tgStatus = services.telegram_bot || "unknown";
+ const disk = health.disk_usage_percent ?? -1;
+ const uptime = health.uptime_hours ?? 0;
+ const ts = health.timestamp;
+ const checkin = health.checkin_summary || {};
+
+ const gwClass = gwStatus === "running" ? "ok" : "down";
+ const tgClass = tgStatus === "connected" ? "ok" : "error";
+ const diskClass = disk > 80 ? "danger" : disk > 60 ? "warning" : "";
+ const diskBarClass = disk > 80 ? "danger" : disk > 60 ? "warning" : "";
+
+ // MCP servers
+ const mcpEntries = Object.entries(services).filter(([k]) => k.startsWith("mcp_"));
+ let mcpHtml = "";
+ if (mcpEntries.length > 0 && !services.mcp_note) {
+ for (const [key, val] of mcpEntries) {
+ const name = key.replace("mcp_", "").replace(/_/g, "-");
+ const cls = val === "ok" ? "ok" : "error";
+ mcpHtml += `
+
+
+
+ ${D.esc(name)}
+ ${D.esc(String(val))}
+
+
`;
+ }
+ }
+
+ el.innerHTML = `
+
+
+
+
+ Gateway
+ ${D.esc(gwStatus)}${uptime > 0 ? ` (${uptime}h)` : ""}
+
+
+
+
+
+ Telegram
+ ${D.esc(tgStatus)}
+
+
+
+
+
+
Disk
+
${disk >= 0 ? disk + "%" : "N/A"}
+ ${disk >= 0 ? `
` : ""}
+
+
+
+
+ Check-ins Today
+ ${checkin.items_reported_today ?? "N/A"} reported · ${checkin.skipped_runs ?? 0} skipped
+
+
+ ${mcpHtml}
+
+ ${ts ? `Last health check: ${D.timeAgo(ts)}
` : ""}
+ `;
+ }
+
+ async function refresh() {
+ const health = await D.fetchApi("health");
+ render(health);
+ }
+
+ D.registerPanel("status", {
+ init: refresh,
+ refresh: refresh,
+ });
+})();
diff --git a/bates-core/plugins/dashboard/static/js/panel-tasks.js b/bates-core/plugins/dashboard/static/js/panel-tasks.js
new file mode 100644
index 0000000..3753236
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/js/panel-tasks.js
@@ -0,0 +1,130 @@
+/**
+ * Tasks Panel — Aggregated Planner + To Do tasks
+ */
+(function () {
+ const D = window.Dashboard;
+ let lastData = null;
+ let sortMode = 'priority'; // priority | due | project
+ let filterProject = 'all';
+ let showCompleted = false;
+
+ const PRI_COLORS = { urgent: '#ff4757', important: '#ffa502', medium: '#00d4ff', low: '#747d8c' };
+ const PRI_ORDER = { urgent: 0, important: 1, medium: 2, low: 3 };
+
+ function priDot(p) {
+ return ``;
+ }
+
+ function renderControls(container) {
+ return `
+
+
+
+
`;
+ }
+
+ function sortTasks(tasks) {
+ const sorted = [...tasks];
+ switch (sortMode) {
+ case 'due':
+ sorted.sort((a, b) => {
+ if (a.completed !== b.completed) return a.completed ? 1 : -1;
+ if (a.dueDate && b.dueDate) return a.dueDate.localeCompare(b.dueDate);
+ if (a.dueDate) return -1;
+ return b.dueDate ? 1 : 0;
+ });
+ break;
+ case 'project':
+ sorted.sort((a, b) => (a.project || '').localeCompare(b.project || '') || (a.priorityNum ?? 5) - (b.priorityNum ?? 5));
+ break;
+ default: // priority
+ sorted.sort((a, b) => {
+ if (a.completed !== b.completed) return a.completed ? 1 : -1;
+ return (a.priorityNum ?? 5) - (b.priorityNum ?? 5) || (a.dueDate || 'z').localeCompare(b.dueDate || 'z');
+ });
+ }
+ return sorted;
+ }
+
+ function renderTaskRow(t) {
+ return D.renderTaskRow(t);
+ }
+
+ function render() {
+ const el = document.getElementById('panel-tasks-body');
+ if (!el || !lastData) return;
+
+ let tasks = lastData.tasks || [];
+ if (filterProject !== 'all') tasks = tasks.filter(t => t.project === filterProject);
+ if (!showCompleted) tasks = tasks.filter(t => !t.completed);
+ tasks = sortTasks(tasks);
+
+ let html = renderControls();
+
+ if (!tasks.length) {
+ html += 'No tasks to display
';
+ } else if (sortMode === 'project') {
+ // Group by project
+ const groups = {};
+ for (const t of tasks) {
+ const k = t.project || 'other';
+ if (!groups[k]) groups[k] = { name: t.planName || k, tasks: [] };
+ groups[k].tasks.push(t);
+ }
+ for (const [k, g] of Object.entries(groups)) {
+ html += `${D.esc(g.name)} (${g.tasks.length})
`;
+ for (const t of g.tasks) html += renderTaskRow(t);
+ html += '
';
+ }
+ } else {
+ for (const t of tasks) html += renderTaskRow(t);
+ }
+
+ html += `Updated ${D.timeAgo(lastData.updated)} · ${lastData.tasks?.length || 0} total tasks
`;
+ el.innerHTML = html;
+
+ // Wire controls
+ document.getElementById('tasks-filter-project')?.addEventListener('change', e => { filterProject = e.target.value; render(); });
+ document.getElementById('tasks-sort')?.addEventListener('change', e => { sortMode = e.target.value; render(); });
+ document.getElementById('tasks-show-done')?.addEventListener('change', e => { showCompleted = e.target.checked; render(); });
+
+ // Wire click-to-open and complete buttons
+ D.wireTaskRows(el, () => { setTimeout(refresh, 1000); });
+ }
+
+ async function refresh() {
+ const el = document.getElementById('panel-tasks-body');
+ if (el && !lastData) el.innerHTML = 'Loading tasks from Planner & To Do…
';
+ try {
+ const data = await D.fetchApi('tasks');
+ if (data && !data.error && !data['jwt-auth-error'] && data.tasks) {
+ lastData = data;
+ // Update overview metrics badge with pending task count
+ const pending = data.tasks.filter(t => !t.completed).length;
+ window._updateOverviewMetrics?.({ tasks: pending });
+ render();
+ } else {
+ if (el) el.innerHTML = `⚠ ${D.esc(data?.error || 'Failed to load tasks')}
`;
+ }
+ } catch (e) {
+ if (el) el.innerHTML = `⚠ ${D.esc(e.message)}
`;
+ }
+ }
+
+ // Expose for project detail modals
+ window._getProjectTasks = function (projectKey) {
+ if (!lastData?.byProject?.[projectKey]) return null;
+ return lastData.byProject[projectKey];
+ };
+
+ D.registerPanel('tasks', { init: refresh, refresh });
+})();
diff --git a/bates-core/plugins/dashboard/static/manifest.json b/bates-core/plugins/dashboard/static/manifest.json
new file mode 100644
index 0000000..b5afb76
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/manifest.json
@@ -0,0 +1,22 @@
+{
+ "name": "Bates Command Center",
+ "short_name": "Bates",
+ "description": "AI Operations Dashboard — Agent orchestration & management",
+ "start_url": "/dashboard/",
+ "display": "standalone",
+ "background_color": "#060a18",
+ "theme_color": "#58c6e8",
+ "orientation": "any",
+ "icons": [
+ {
+ "src": "/dashboard/assets/app-icon-small.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/dashboard/assets/app-icon-small.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ]
+}
diff --git a/bates-core/plugins/dashboard/static/styles.css b/bates-core/plugins/dashboard/static/styles.css
new file mode 100644
index 0000000..3480ba5
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/styles.css
@@ -0,0 +1,2100 @@
+/* ═══════════════════════════════════════════════════════════
+ OpenClaw Command Center — Glassmorphism Design System v5
+ Inspired by: Crypto Wallet glassmorphism aesthetic
+ ═══════════════════════════════════════════════════════════ */
+
+:root {
+ --bg: #060a14;
+ --glass-bg: rgba(12, 20, 45, 0.2);
+ --glass-bg-hover: rgba(20, 35, 70, 0.3);
+ --glass-border: rgba(90, 200, 232, 0.6);
+ --glass-border-hover: rgba(90, 200, 232, 0.85);
+ --glass-blur: blur(24px);
+ --nav-bg: rgba(8, 12, 25, 0.4);
+
+ --blue: #1F4E8C;
+ --blue-lt: #3B7DD8;
+ --blue-glow: 0 0 20px rgba(31, 78, 140, 0.3);
+ --orange: #F08C2E;
+ --red: #D6452A;
+ --green: #22C55E;
+ --teal: #14B8A6;
+ --purple: #8B5CF6;
+
+ --text: #E8EAED;
+ --text2: rgba(255, 255, 255, 0.5);
+ --text3: rgba(255, 255, 255, 0.3);
+ --text-muted: rgba(255, 255, 255, 0.25);
+
+ --font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
+ --mono: var(--font-mono);
+ --r: 12px;
+ --r-sm: 8px;
+ --topbar: 56px;
+ --chat-w: 380px;
+}
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+html, body {
+ height: 100%;
+ background-color: #060a14;
+ background-image: url('/dashboard/assets/bg.png');
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
+ background-attachment: fixed;
+ color: var(--text);
+ font: 13px/1.5 var(--font);
+ -webkit-font-smoothing: antialiased;
+ overflow: hidden;
+}
+
+/* Dark overlay on top of background image — disabled, bg already blurred/matte */
+#bg-overlay {
+ display: none;
+}
+
+/* Scrollbar */
+::-webkit-scrollbar { width: 5px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 4px; }
+::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
+
+/* ─── Glass Card (core component) ─── */
+.glass-card {
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-top: 1px solid rgba(90, 200, 232, 0.5);
+ border-radius: var(--r);
+ box-shadow: 0 0 8px rgba(90, 200, 232, 0.25), 0 0 20px rgba(90, 200, 232, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.06);
+ transition: border-color 0.3s, box-shadow 0.3s;
+}
+.glass-card:hover {
+ border-color: var(--glass-border-hover);
+ box-shadow: 0 0 12px rgba(90, 200, 232, 0.35), 0 0 30px rgba(90, 200, 232, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08);
+}
+
+.glass-panel {
+ background: rgba(10, 18, 40, 0.25);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-top: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: var(--r);
+ box-shadow: 0 0 8px rgba(90, 200, 232, 0.25), 0 0 20px rgba(90, 200, 232, 0.1);
+}
+
+.glass-nav {
+ background: var(--nav-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border-bottom: 1px solid rgba(90, 200, 232, 0.4);
+ box-shadow: 0 0 10px rgba(90, 200, 232, 0.15), 0 4px 20px rgba(0, 0, 0, 0.3);
+}
+
+/* ═══════════════ TOP BAR ═══════════════ */
+.topbar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: var(--topbar);
+ display: flex;
+ align-items: center;
+ padding: 0 16px;
+ z-index: 100;
+ background: var(--nav-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border-bottom: 1px solid var(--glass-border);
+ gap: 12px;
+}
+
+.topbar-left {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.topbar-logo {
+ height: 36px;
+ width: auto;
+ object-fit: contain;
+}
+
+.topbar-logo-fallback {
+ font-weight: 700;
+ font-size: 16px;
+ letter-spacing: 1px;
+ color: var(--blue-lt);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.topbar-nav {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin: 0 auto;
+ flex-shrink: 0;
+}
+
+.nav-tab {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 14px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--text2);
+ font: 12px/1 var(--font);
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: nowrap;
+}
+.nav-tab:hover {
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--text);
+}
+.nav-tab.active {
+ background: rgba(31, 78, 140, 0.3);
+ color: #fff;
+ box-shadow: var(--blue-glow), inset 0 0 0 1px rgba(31, 78, 140, 0.3);
+}
+.nav-icon { font-size: 14px; }
+.nav-label { font-size: 12px; }
+
+.topbar-right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-shrink: 0;
+ margin-left: auto;
+}
+
+.topbar-clock {
+ font: 500 13px/1 var(--font-mono);
+ color: var(--text2);
+ letter-spacing: 0.5px;
+}
+
+.conn-badge {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.conn-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #555;
+ transition: background 0.3s;
+}
+.conn-dot.connected { background: var(--green); box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); }
+.conn-dot.disconnected { background: var(--red); }
+.conn-dot.reconnecting { background: var(--orange); animation: pulse 1.5s infinite; }
+.conn-label {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--text3);
+}
+
+.chat-toggle-btn {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border: 1px solid var(--glass-border);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--text2);
+ font-size: 16px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+.topbar-avatar {
+ height: 36px;
+ width: auto;
+ object-fit: contain;
+ filter: drop-shadow(0 0 6px rgba(90, 200, 232, 0.3));
+}
+.topbar-title {
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: 2px;
+ color: #fff;
+ text-shadow: 0 0 15px rgba(90, 200, 232, 0.4);
+}
+.chat-toggle-btn:hover { background: rgba(255, 255, 255, 0.08); }
+.chat-toggle-btn.active { background: rgba(31, 78, 140, 0.3); border-color: rgba(31, 78, 140, 0.4); }
+
+/* ═══════════════ APP SHELL ═══════════════ */
+.app-shell {
+ position: fixed;
+ top: var(--topbar);
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ z-index: 1;
+}
+
+.content-area {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 20px 24px;
+ padding-right: calc(var(--chat-w) + 24px);
+}
+
+/* ─── Views ─── */
+.view { display: none; }
+.view.active { display: block; }
+
+/* ─── Sections ─── */
+.section-label {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1.5px;
+ color: var(--text3);
+ margin: 24px 0 12px;
+}
+
+/* ─── Cards ─── */
+.card {
+ margin-bottom: 16px;
+}
+.card-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--glass-border);
+}
+.card-head h3 {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+ margin: 0;
+ text-shadow: 0 0 20px rgba(59, 125, 216, 0.15);
+}
+.card-body {
+ padding: 14px 18px;
+}
+.card-body.scroll-y {
+ max-height: 360px;
+ overflow-y: auto;
+}
+
+.refresh-btn {
+ background: transparent;
+ border: 1px solid var(--glass-border);
+ color: var(--text2);
+ border-radius: 6px;
+ padding: 3px 8px;
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+.refresh-btn:hover {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: var(--glass-border-hover);
+ color: var(--text);
+}
+
+/* ─── Grid layouts ─── */
+.grid-2col {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+
+/* ═══════════════ OVERVIEW TAB ═══════════════ */
+
+/* Metric strip */
+.metric-strip {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 12px;
+ margin-bottom: 8px;
+}
+.metric {
+ padding: 18px 16px;
+ text-align: center;
+ position: relative;
+ overflow: hidden;
+}
+.metric::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 40%;
+ height: 2px;
+ background: linear-gradient(90deg, transparent, var(--blue-lt), transparent);
+ opacity: 0.6;
+}
+.metric-val {
+ display: block;
+ font-size: 24px;
+ font-weight: 700;
+ color: #fff;
+ margin-bottom: 4px;
+ font-variant-numeric: tabular-nums;
+}
+.metric-lbl {
+ display: block;
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--text3);
+}
+
+/* Projects row */
+.projects-row {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 12px;
+ margin-bottom: 20px;
+}
+.project-box {
+ padding: 16px;
+ cursor: pointer;
+ transition: all 0.25s;
+ position: relative;
+ overflow: hidden;
+}
+.project-box::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: var(--accent, var(--blue));
+ opacity: 0.9;
+ box-shadow: 0 0 12px var(--accent, var(--blue)), 0 0 4px var(--accent, var(--blue));
+}
+.project-box:hover {
+ border-color: var(--glass-border-hover);
+ transform: translateY(-2px);
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), 0 0 20px color-mix(in srgb, var(--accent, var(--blue)) 25%, transparent);
+}
+.project-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+.project-icon { font-size: 18px; }
+.project-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+}
+.project-deputy {
+ font-size: 11px;
+ color: var(--text2);
+ margin-bottom: 8px;
+}
+
+/* Add Project card */
+.project-add {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100px;
+ border: 2px dashed var(--glass-border);
+ background: transparent;
+ opacity: 0.6;
+ transition: opacity 0.2s;
+}
+.project-add:hover { opacity: 1; }
+.project-add::before { display: none; }
+.project-add-icon { font-size: 28px; color: var(--blue-lt); }
+.project-add-label { font-size: 11px; color: var(--text3); margin-top: 4px; }
+
+/* Project form */
+.project-form { max-width: 500px; }
+.pf-label { display: block; font-size: 11px; font-weight: 600; color: var(--text2); margin: 10px 0 4px; text-transform: uppercase; letter-spacing: 0.5px; }
+.pf-input {
+ width: 100%; padding: 8px 12px; border-radius: 6px;
+ border: 1px solid var(--glass-border); background: rgba(255,255,255,0.04);
+ color: var(--text); font-size: 12px; font-family: inherit; outline: none;
+}
+.pf-input:focus { border-color: rgba(88,198,232,0.5); }
+.pf-btn {
+ padding: 8px 16px; border-radius: 6px; border: none; font-size: 12px;
+ font-weight: 600; cursor: pointer; font-family: inherit; transition: all 0.2s;
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
+ text-rendering: geometricPrecision; transform: translateZ(0);
+}
+.pf-btn-primary { background: rgba(88,198,232,0.2); color: #58c6e8; }
+.pf-btn-primary:hover { background: rgba(88,198,232,0.35); }
+.pf-btn-danger { background: rgba(239,68,68,0.15); color: #ef4444; }
+.pf-btn-danger:hover { background: rgba(239,68,68,0.3); }
+.pf-btn-cancel { background: rgba(255,255,255,0.05); color: var(--text3); }
+.pf-btn-cancel:hover { background: rgba(255,255,255,0.1); }
+.pf-btn-small { padding: 4px 10px; font-size: 10px; background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid var(--glass-border); border-radius: 4px; cursor: pointer;
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
+ text-rendering: geometricPrecision; transform: translateZ(0); }
+.pf-btn-small:hover { background: rgba(255,255,255,0.1); }
+.project-deputy strong {
+ color: var(--text);
+ font-weight: 500;
+}
+.project-body {
+ font-size: 11px;
+ color: var(--text3);
+ line-height: 1.6;
+ max-height: 80px;
+ overflow-y: auto;
+}
+
+/* ─── Tasks ─── */
+.task-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 10px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+}
+.task-item:last-child { border-bottom: none; }
+.priority-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ margin-top: 6px;
+ flex-shrink: 0;
+}
+.priority-dot.high { background: var(--red); box-shadow: 0 0 6px rgba(214, 69, 42, 0.4); }
+.priority-dot.medium { background: var(--orange); }
+.priority-dot.low { background: var(--green); }
+.priority-dot.none { background: var(--text3); }
+.task-check {
+ width: 14px;
+ height: 14px;
+ border: 1.5px solid var(--text3);
+ border-radius: 4px;
+ margin-top: 2px;
+ flex-shrink: 0;
+}
+.task-check.done {
+ background: var(--green);
+ border-color: var(--green);
+}
+.task-info { flex: 1; min-width: 0; }
+.task-title {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text);
+ line-height: 1.4;
+}
+.task-title.done { text-decoration: line-through; color: var(--text3); }
+.task-meta {
+ display: flex;
+ gap: 10px;
+ font-size: 10px;
+ color: var(--text3);
+ margin-top: 2px;
+}
+
+/* ─── Upcoming crons ─── */
+.upcoming-card {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+ font-size: 12px;
+}
+.upcoming-card:last-child { border-bottom: none; }
+.upcoming-name { color: var(--text); font-weight: 500; }
+.upcoming-time { color: var(--text3); font-size: 11px; font-family: var(--font-mono); }
+
+/* ═══════════════ AGENTS TAB ═══════════════ */
+.org-chart {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 8px 0;
+}
+
+.org-tier {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 12px;
+ position: relative;
+}
+.org-tier-label {
+ width: 100%;
+ text-align: center;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1.5px;
+ color: var(--text3);
+ margin-bottom: 8px;
+}
+
+.org-line {
+ width: 2px;
+ height: 28px;
+ background: linear-gradient(to bottom, rgba(31, 78, 140, 0.4), rgba(31, 78, 140, 0.1));
+ margin: 12px auto;
+ position: relative;
+}
+.org-line::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--blue);
+ box-shadow: 0 0 8px rgba(31, 78, 140, 0.5);
+}
+
+.acard {
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--r);
+ padding: 16px;
+ text-align: center;
+ min-width: 130px;
+ max-width: 160px;
+ cursor: pointer;
+ transition: all 0.25s;
+}
+.acard:hover {
+ border-color: var(--glass-border-hover);
+ transform: translateY(-3px);
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
+}
+.acard.coo {
+ border-color: rgba(31, 78, 140, 0.4);
+ box-shadow: var(--blue-glow);
+ min-width: 170px;
+ max-width: 200px;
+ padding: 20px;
+}
+.acard.coo:hover {
+ border-color: rgba(31, 78, 140, 0.6);
+}
+
+.aname {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+ margin-bottom: 2px;
+}
+.arole {
+ font-size: 10px;
+ color: var(--text2);
+ margin-bottom: 8px;
+}
+.ameta {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ font-size: 10px;
+ color: var(--text3);
+ margin-top: 6px;
+}
+.agent-counts {
+ display: flex;
+ justify-content: center;
+ gap: 10px;
+ font-size: 10px;
+ color: var(--text3);
+ margin-top: 4px;
+}
+
+/* Model badges */
+.model-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 9px;
+ font-weight: 600;
+ font-family: var(--font-mono);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+.model-badge.opus { background: rgba(31, 78, 140, 0.2); color: var(--blue-lt); border: 1px solid rgba(31, 78, 140, 0.3); }
+.model-badge.sonnet { background: rgba(240, 140, 46, 0.15); color: var(--orange); border: 1px solid rgba(240, 140, 46, 0.25); }
+.model-badge.gemini { background: rgba(34, 197, 94, 0.15); color: var(--green); border: 1px solid rgba(34, 197, 94, 0.25); }
+.model-badge.codex { background: rgba(139, 92, 246, 0.15); color: #a78bfa; border: 1px solid rgba(139, 92, 246, 0.25); }
+.model-badge.other { background: rgba(255, 255, 255, 0.05); color: var(--text3); border: 1px solid rgba(255, 255, 255, 0.08); }
+
+/* Status dots */
+.status-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #555;
+ flex-shrink: 0;
+}
+.status-dot.active, .status-dot.running, .status-dot.ok, .status-dot.connected { background: var(--green); box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); }
+.status-dot.idle { background: var(--text3); }
+.status-dot.error, .status-dot.down, .status-dot.failed { background: var(--red); }
+
+/* Sub-agent sections */
+.agent-section-header {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1.2px;
+ color: var(--text3);
+ margin-bottom: 8px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid var(--glass-border);
+}
+
+.agent-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.agent-card {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ border-radius: var(--r-sm);
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid transparent;
+ transition: all 0.2s;
+}
+.agent-card:hover {
+ background: rgba(255, 255, 255, 0.04);
+ border-color: var(--glass-border);
+}
+.agent-card.subagent-running {
+ border-color: rgba(34, 197, 94, 0.2);
+ background: rgba(34, 197, 94, 0.04);
+}
+
+.agent-avatar { font-size: 20px; flex-shrink: 0; }
+.agent-info { flex: 1; min-width: 0; }
+.agent-name { font-size: 12px; font-weight: 600; color: var(--text); }
+.agent-role { font-size: 11px; color: var(--text2); }
+.agent-detail { font-size: 11px; color: var(--text3); }
+.subagent-task { font-size: 11px; color: var(--text2); margin-top: 4px; line-height: 1.4; }
+
+.agent-status {
+ font-size: 10px;
+ font-weight: 600;
+ padding: 3px 8px;
+ border-radius: 4px;
+ white-space: nowrap;
+}
+.agent-status-running { background: rgba(34, 197, 94, 0.12); color: var(--green); }
+.agent-status-completed { background: rgba(255, 255, 255, 0.06); color: var(--text3); }
+.agent-status-failed { background: rgba(214, 69, 42, 0.12); color: var(--red); }
+
+/* ═══════════════ OPERATIONS TAB ═══════════════ */
+
+/* Operations sub-nav */
+.ops-nav {
+ display: flex;
+ gap: 2px;
+ margin-bottom: 16px;
+ border-bottom: 1px solid var(--glass-border);
+ padding-bottom: 0;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ background: var(--bg, #060a18);
+}
+.ops-nav-btn {
+ padding: 8px 16px;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text3);
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ transition: all 0.2s;
+ font-family: inherit;
+ margin-bottom: -1px;
+}
+.ops-nav-btn:hover { color: var(--text); }
+.ops-nav-btn.active {
+ color: #58c6e8;
+ border-bottom-color: #58c6e8;
+}
+
+/* Operations collapsible boxes */
+.ops-section { margin-bottom: 12px; }
+.ops-box {
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--r);
+ overflow: hidden;
+ transition: border-color 0.3s;
+}
+.ops-box:hover { border-color: var(--glass-border-hover); }
+.ops-box-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.15s;
+}
+.ops-box-head:hover { background: rgba(255, 255, 255, 0.03); }
+.ops-box-head h3 {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+ margin: 0;
+}
+.ops-box-chevron {
+ width: 0; height: 0;
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-top: 6px solid var(--text2);
+ transition: transform 0.2s;
+}
+.ops-box.collapsed .ops-box-chevron { transform: rotate(-90deg); }
+.ops-box.collapsed .ops-box-body { display: none; }
+.ops-box-body { padding: 0 16px 16px; }
+.ops-box-body.scroll-y { max-height: 500px; overflow-y: auto; }
+
+/* Cron grid */
+.cron-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+}
+.cron-cat-label {
+ grid-column: 1 / -1;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1.5px;
+ color: var(--text3);
+ padding: 12px 0 4px;
+ border-bottom: 1px solid var(--glass-border);
+ margin-bottom: 4px;
+}
+.cron-cat-label .cnt {
+ font-size: 10px;
+ font-weight: 400;
+ color: var(--text3);
+ background: rgba(255, 255, 255, 0.06);
+ padding: 1px 6px;
+ border-radius: 4px;
+ margin-left: 6px;
+}
+
+.cron-card {
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--r-sm);
+ padding: 14px;
+ transition: all 0.2s;
+}
+.cron-card:hover {
+ border-color: var(--glass-border-hover);
+ transform: translateY(-1px);
+}
+.cron-card.running {
+ border-color: rgba(34, 197, 94, 0.3);
+}
+.cron-card.disabled {
+ opacity: 0.4;
+}
+
+.cron-name {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text);
+ margin-bottom: 4px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.cron-schedule {
+ font-size: 11px;
+ font-family: var(--font-mono);
+ color: var(--blue-lt);
+ margin-bottom: 8px;
+}
+.cron-meta {
+ font-size: 10px;
+ color: var(--text3);
+ line-height: 1.6;
+}
+.cron-meta span { display: block; }
+.cron-actions {
+ display: flex;
+ gap: 6px;
+ margin-top: 10px;
+}
+.cron-action-btn {
+ flex: 1;
+ padding: 5px 0;
+ border: 1px solid var(--glass-border);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text2);
+ font-size: 10px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+.cron-action-btn:hover {
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--text);
+}
+
+/* Delegations */
+.deleg-paths {
+ display: flex;
+ gap: 8px;
+ margin-top: 4px;
+ font-size: 10px;
+}
+.deleg-path {
+ color: var(--text3);
+ font-family: var(--font-mono);
+ font-size: 10px;
+}
+
+/* ═══════════════ STANDUP TAB ═══════════════ */
+.standup-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+}
+.standup-header h2 {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text);
+}
+.standup-actions {
+ display: flex;
+ gap: 8px;
+}
+.standup-btn {
+ padding: 7px 16px;
+ border-radius: 8px;
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ border: none;
+}
+.standup-btn-primary {
+ background: rgba(31, 78, 140, 0.3);
+ color: var(--blue-lt);
+ border: 1px solid rgba(31, 78, 140, 0.3);
+}
+.standup-btn-primary:hover { background: rgba(31, 78, 140, 0.45); }
+.standup-btn-secondary {
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--text2);
+ border: 1px solid var(--glass-border);
+}
+.standup-btn-secondary:hover { background: rgba(255, 255, 255, 0.08); }
+
+.standup-empty {
+ text-align: center;
+ padding: 48px 0;
+}
+.standup-empty-icon { font-size: 36px; margin-bottom: 12px; }
+.standup-empty-text { font-size: 14px; color: var(--text2); margin-bottom: 6px; }
+.standup-empty-sub { font-size: 12px; color: var(--text3); }
+
+.standup-thread {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.standup-msg {
+ display: flex;
+ gap: 12px;
+ padding: 14px 16px;
+ border-radius: var(--r);
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+}
+.standup-avatar {
+ font-size: 24px;
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.standup-msg-body { flex: 1; min-width: 0; }
+.standup-msg-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+.standup-msg-name { font-size: 13px; font-weight: 600; color: var(--text); }
+.standup-msg-role {
+ font-size: 10px;
+ padding: 2px 8px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--text3);
+}
+.standup-msg-time { font-size: 10px; color: var(--text3); margin-left: auto; font-family: var(--font-mono); }
+.standup-msg-text {
+ font-size: 12px;
+ color: var(--text2);
+ line-height: 1.6;
+}
+.standup-msg-text strong.standup-label {
+ color: var(--blue-lt);
+ display: inline-block;
+ margin-top: 4px;
+}
+.standup-nav {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.standup-btn-nav {
+ background: rgba(255,255,255,0.05);
+ color: var(--text2);
+ border: 1px solid var(--glass-border);
+ padding: 4px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+}
+.standup-btn-nav:hover { background: rgba(255,255,255,0.1); }
+.standup-date {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+ font-family: var(--font-mono);
+}
+.standup-date-picker {
+ background: rgba(255,255,255,0.05);
+ border: 1px solid var(--glass-border);
+ color: var(--text2);
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-family: var(--font-mono);
+ cursor: pointer;
+}
+.standup-summary {
+ font-size: 12px;
+ color: var(--text3);
+ margin-bottom: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--glass-border);
+}
+
+/* ═══════════════ MEMORY TAB ═══════════════ */
+.memory-feed { display: flex; flex-direction: column; gap: 4px; }
+.memory-entry {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 8px 4px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.03);
+ font-size: 12px;
+}
+.memory-timestamp {
+ font-size: 10px;
+ font-family: var(--font-mono);
+ color: var(--text3);
+ min-width: 50px;
+ flex-shrink: 0;
+}
+.memory-tag {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ flex-shrink: 0;
+}
+.memory-tag.goal { background: rgba(31, 78, 140, 0.15); color: var(--blue-lt); }
+.memory-tag.fact { background: rgba(34, 197, 94, 0.1); color: var(--green); }
+.memory-tag.preference { background: rgba(139, 92, 246, 0.1); color: var(--purple); }
+.memory-tag.deadline { background: rgba(240, 140, 46, 0.1); color: var(--orange); }
+.memory-tag.decision { background: rgba(20, 184, 166, 0.1); color: var(--teal); }
+.memory-tag.contact { background: rgba(255, 255, 255, 0.06); color: var(--text2); }
+.memory-tag.pattern { background: rgba(59, 125, 216, 0.1); color: var(--blue-lt); }
+.memory-tag.agent { background: rgba(240, 140, 46, 0.1); color: var(--orange); }
+.memory-content { color: var(--text2); line-height: 1.5; }
+
+.filter-bar {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+.filter-btn {
+ padding: 4px 10px;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--text3);
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: capitalize;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+.filter-btn:hover { background: rgba(255, 255, 255, 0.08); color: var(--text2); }
+.filter-btn.active {
+ background: rgba(31, 78, 140, 0.25);
+ color: var(--blue-lt);
+ border-color: rgba(31, 78, 140, 0.3);
+}
+
+/* ═══════════════ COSTS & SETTINGS ═══════════════ */
+/* Integrations panel */
+.integ-section-title {
+ font-size: 10px; font-weight: 600; text-transform: uppercase;
+ letter-spacing: 1px; color: var(--text3); margin-bottom: 8px; margin-top: 4px;
+}
+.integ-list { display: flex; flex-direction: column; gap: 4px; }
+.integ-row {
+ display: flex; align-items: center; gap: 10px;
+ padding: 6px 8px; border-radius: 6px;
+ background: rgba(255,255,255,0.02);
+ transition: background .15s;
+}
+.integ-row:hover { background: rgba(255,255,255,0.05); }
+.integ-status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+.integ-info { min-width: 0; }
+.integ-name { font-size: 12px; font-weight: 500; color: var(--text); display: flex; align-items: center; gap: 6px; }
+.integ-detail { font-size: 10px; color: var(--text3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.integ-badge {
+ font-size: 8px; padding: 1px 5px; border-radius: 3px; font-weight: 600; letter-spacing: .5px;
+}
+.integ-badge-read { background: rgba(34,197,94,.12); color: var(--green); }
+.integ-badge-write { background: rgba(240,140,46,.12); color: var(--orange); }
+
+/* Costs panel */
+.cost-summary {
+ text-align: center; padding: 12px 0 14px;
+ border-bottom: 1px solid rgba(255,255,255,0.05); margin-bottom: 12px;
+}
+.cost-summary-label { font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: var(--text3); }
+.cost-summary-value { font-size: 22px; font-weight: 700; color: var(--text); margin: 4px 0 2px; }
+.cost-summary-sub { font-size: 10px; color: var(--text3); }
+.cost-table { display: flex; flex-direction: column; gap: 2px; }
+.cost-table-head {
+ display: grid; grid-template-columns: 1fr auto auto; gap: 8px;
+ font-size: 9px; text-transform: uppercase; letter-spacing: .8px;
+ color: var(--text3); padding: 0 4px 6px; border-bottom: 1px solid rgba(255,255,255,0.05);
+}
+.cost-table-row {
+ display: grid; grid-template-columns: 1fr auto auto; gap: 8px; align-items: center;
+ padding: 6px 4px; border-bottom: 1px solid rgba(255,255,255,0.02);
+}
+.cost-svc-name { font-size: 12px; font-weight: 500; color: var(--text); }
+.cost-svc-plan { font-size: 10px; color: var(--text3); }
+.cost-amount { font-size: 12px; font-weight: 500; color: var(--text); white-space: nowrap; }
+.cost-type-badge {
+ font-size: 8px; padding: 2px 6px; border-radius: 3px; font-weight: 600; white-space: nowrap;
+}
+.cost-type-flat { background: rgba(31,78,140,.15); color: var(--blue-lt); }
+.cost-type-usage { background: rgba(240,140,46,.12); color: var(--orange); }
+.cost-note {
+ font-size: 11px; color: var(--text3); margin-top: 10px;
+ padding: 8px; border-radius: 6px; background: rgba(255,255,255,0.03);
+}
+
+/* Settings */
+/* ═══════════════ SETTINGS SUB-TABS ═══════════════ */
+.s-tabs {
+ display: flex;
+ gap: 2px;
+ margin-bottom: 12px;
+ border-bottom: 1px solid var(--glass-border);
+ padding-bottom: 0;
+}
+.s-tab {
+ padding: 6px 14px;
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text2);
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ transition: all 0.2s;
+ font-family: inherit;
+ margin-bottom: -1px;
+}
+.s-tab:hover { color: var(--text); }
+.s-tab.active {
+ color: #58c6e8;
+ border-bottom-color: #58c6e8;
+}
+.s-empty {
+ font-size: 11px;
+ color: var(--text3);
+ padding: 16px 0;
+ text-align: center;
+}
+
+/* ═══════════════ COLLAPSIBLE CARDS ═══════════════ */
+.s-card {
+ border-radius: var(--r-sm);
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+ margin-bottom: 8px;
+ overflow: hidden;
+}
+.s-card-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 12px;
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.15s;
+}
+.s-card-head:hover { background: rgba(255, 255, 255, 0.04); }
+.s-card-title {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1.5px;
+ color: var(--text);
+}
+.s-card-chevron {
+ width: 0; height: 0;
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-top: 5px solid var(--text2);
+ transition: transform 0.2s;
+}
+.s-card.collapsed .s-card-chevron { transform: rotate(-90deg); }
+.s-card.collapsed .s-card-body { display: none; }
+.s-card-body { padding: 0 12px 10px; }
+.s-card-body .hidden { display: none; }
+
+.settings-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 8px;
+}
+.settings-grid .s-card { margin-bottom: 0; }
+.settings-card {
+ padding: 12px;
+ border-radius: var(--r-sm);
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+}
+.settings-card-title {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--text3);
+ margin-bottom: 8px;
+}
+.settings-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 4px 0;
+ font-size: 11px;
+}
+.settings-row-label { color: var(--text); }
+.settings-row-value { color: var(--blue-lt); font-family: var(--font-mono); font-size: 11px; }
+
+/* ═══════════════ WHITELIST EDITOR ═══════════════ */
+.wl-section { padding: 2px 0; }
+.wl-label {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1.5px;
+ color: var(--text);
+ margin-bottom: 6px;
+}
+.wl-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 8px;
+ align-items: center;
+}
+.wl-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 8px;
+ border-radius: 4px;
+ background: rgba(88, 198, 232, 0.12);
+ border: 1px solid rgba(88, 198, 232, 0.25);
+ font-size: 11px;
+ font-family: var(--font-mono);
+ color: #93d4ef;
+}
+.wl-tag-rm {
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.35);
+ cursor: pointer;
+ font-size: 13px;
+ padding: 0 2px;
+ line-height: 1;
+ transition: color 0.15s;
+}
+.wl-tag-rm:hover { color: #f87171; }
+.wl-add-row {
+ display: inline-flex;
+ gap: 4px;
+ align-items: center;
+}
+.wl-add-input {
+ width: 150px;
+ padding: 3px 8px;
+ border-radius: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--text);
+ font-size: 11px;
+ font-family: var(--font-mono);
+ outline: none;
+}
+.wl-add-input:focus { border-color: rgba(88, 198, 232, 0.4); }
+.wl-add-input::placeholder { color: rgba(255,255,255,0.15); }
+.wl-add-btn {
+ padding: 3px 8px;
+ border-radius: 4px;
+ border: 1px solid rgba(88, 198, 232, 0.3);
+ background: rgba(88, 198, 232, 0.1);
+ color: #58c6e8;
+ font-size: 13px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background 0.15s;
+ line-height: 1;
+}
+.wl-add-btn:hover { background: rgba(88, 198, 232, 0.25); }
+
+/* ═══════════════ M365 SAFETY TOGGLE ═══════════════ */
+.safety-card {
+ padding: 14px;
+ border-radius: var(--r-sm);
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid rgba(88, 198, 232, 0.15);
+ margin-bottom: 12px;
+}
+.safety-card-danger {
+ background: rgba(220, 38, 38, 0.08);
+ border-color: rgba(220, 38, 38, 0.4);
+ animation: danger-pulse 2s ease-in-out infinite;
+}
+@keyframes danger-pulse {
+ 0%, 100% { border-color: rgba(220, 38, 38, 0.4); }
+ 50% { border-color: rgba(220, 38, 38, 0.8); }
+}
+.safety-status-ok { color: #4ade80; }
+.safety-status-danger { color: #f87171; font-weight: 700; }
+.safety-toggle-section { margin-top: 10px; }
+.safety-hint {
+ font-size: 10px;
+ color: var(--text3);
+ margin-top: 6px;
+}
+.safety-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 14px;
+ border-radius: 6px;
+ border: 1px solid;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ font-family: inherit;
+}
+.safety-btn-icon { font-size: 13px; }
+.safety-btn-danger {
+ background: rgba(220, 38, 38, 0.15);
+ border-color: rgba(220, 38, 38, 0.4);
+ color: #fca5a5;
+}
+.safety-btn-danger:hover {
+ background: rgba(220, 38, 38, 0.3);
+ border-color: rgba(220, 38, 38, 0.7);
+}
+.safety-btn-restore {
+ background: rgba(34, 197, 94, 0.15);
+ border-color: rgba(34, 197, 94, 0.4);
+ color: #86efac;
+}
+.safety-btn-restore:hover {
+ background: rgba(34, 197, 94, 0.3);
+ border-color: rgba(34, 197, 94, 0.7);
+}
+.safety-btn-cancel {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.2);
+ color: var(--text2);
+}
+.safety-btn-cancel:hover {
+ background: rgba(255, 255, 255, 0.1);
+}
+.safety-btn-confirm-danger {
+ background: rgba(220, 38, 38, 0.3);
+ border-color: rgba(220, 38, 38, 0.6);
+ color: #fecaca;
+}
+.safety-btn-confirm-danger:hover:not(:disabled) {
+ background: rgba(220, 38, 38, 0.5);
+}
+.safety-btn-confirm-danger:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+.safety-btn-small {
+ padding: 3px 10px;
+ font-size: 10px;
+ background: rgba(255,255,255,0.05);
+ border-color: rgba(255,255,255,0.2);
+ color: var(--text2);
+}
+.safety-warning-banner {
+ display: flex;
+ gap: 10px;
+ padding: 10px 12px;
+ border-radius: 6px;
+ background: rgba(220, 38, 38, 0.12);
+ border: 1px solid rgba(220, 38, 38, 0.3);
+ margin-bottom: 10px;
+}
+.safety-warning-icon { font-size: 24px; flex-shrink: 0; }
+.safety-warning-text { font-size: 11px; color: #fca5a5; line-height: 1.5; }
+.safety-restart-banner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ margin-bottom: 10px;
+ border-radius: 6px;
+ background: rgba(234, 179, 8, 0.15);
+ border: 1px solid rgba(234, 179, 8, 0.3);
+ font-size: 11px;
+ color: #fde68a;
+}
+
+/* Confirmation dialog overlay */
+.safety-overlay {
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 9999;
+ animation: overlay-in 0.2s ease;
+}
+@keyframes overlay-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+.safety-dialog {
+ background: #1a1a2e;
+ border: 2px solid rgba(220, 38, 38, 0.5);
+ border-radius: 12px;
+ padding: 24px;
+ max-width: 480px;
+ width: 90%;
+ box-shadow: 0 0 40px rgba(220, 38, 38, 0.2);
+ animation: dialog-in 0.3s ease;
+}
+@keyframes dialog-in {
+ from { transform: scale(0.95); opacity: 0; }
+ to { transform: scale(1); opacity: 1; }
+}
+.safety-dialog-header {
+ text-align: center;
+ font-size: 28px;
+ margin-bottom: 8px;
+ animation: siren-flash 1s ease-in-out infinite;
+}
+@keyframes siren-flash {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+.safety-dialog-siren { margin: 0 6px; }
+.safety-dialog-title {
+ text-align: center;
+ font-size: 16px;
+ font-weight: 700;
+ color: #f87171;
+ margin: 0 0 16px 0;
+}
+.safety-dialog-body {
+ font-size: 12px;
+ color: var(--text2);
+ line-height: 1.6;
+}
+.safety-dialog-body ul {
+ margin: 8px 0;
+ padding-left: 20px;
+}
+.safety-dialog-body li {
+ margin: 4px 0;
+ color: #fca5a5;
+}
+.safety-dialog-warning {
+ color: #fde68a;
+ font-weight: 600;
+ margin-top: 12px;
+}
+.safety-dialog-confirm {
+ margin: 16px 0;
+ padding: 10px;
+ border-radius: 6px;
+ background: rgba(220, 38, 38, 0.1);
+ border: 1px solid rgba(220, 38, 38, 0.2);
+}
+.safety-dialog-checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: #fca5a5;
+ cursor: pointer;
+}
+.safety-dialog-checkbox-label input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ accent-color: #dc2626;
+}
+.safety-dialog-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 16px;
+}
+
+/* ═══════════════ INTEGRATIONS ═══════════════ */
+.data-table-simple {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+.data-table-simple th {
+ text-align: left;
+ padding: 8px 10px;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--text3);
+ border-bottom: 1px solid var(--glass-border);
+}
+.data-table-simple td {
+ padding: 8px 10px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.03);
+ color: var(--text);
+}
+.data-table-simple tr:last-child td { border-bottom: none; }
+
+/* ═══════════════ FILES ═══════════════ */
+.file-list { display: flex; flex-direction: column; }
+.file-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 4px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.03);
+}
+.file-item:last-child { border-bottom: none; }
+.file-icon { font-size: 16px; flex-shrink: 0; width: 24px; text-align: center; }
+.file-info { flex: 1; min-width: 0; }
+.file-name { font-size: 12px; font-weight: 500; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.file-link { color: rgba(90, 200, 232, 0.9); text-decoration: none; transition: color 0.2s; }
+.file-link:hover { color: #fff; text-shadow: 0 0 8px rgba(90, 200, 232, 0.4); }
+.file-item.clickable { cursor: pointer; }
+.file-item.clickable:hover { background: rgba(90, 200, 232, 0.05); border-radius: 6px; }
+.file-path { font-size: 10px; color: var(--text3); font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.file-meta { text-align: right; font-size: 10px; color: var(--text3); flex-shrink: 0; }
+
+/* ═══════════════ ROLLOUT ═══════════════ */
+.rollout-progress-wrap { margin-bottom: 16px; }
+.rollout-progress-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
+.rollout-progress-pct { font-size: 12px; font-weight: 600; color: var(--text); }
+.rollout-progress-bar { height: 6px; border-radius: 3px; background: rgba(255, 255, 255, 0.06); overflow: hidden; }
+.rollout-progress-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, var(--blue), var(--blue-lt)); transition: width 0.5s; }
+.cron-section-label {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1.2px;
+ color: var(--text3);
+}
+.cron-section-label .count {
+ font-weight: 400;
+ color: var(--text3);
+ font-size: 10px;
+}
+
+.rollout-layer { margin-bottom: 16px; }
+.rollout-agent-list { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
+.rollout-agent {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: var(--r-sm);
+ background: rgba(255, 255, 255, 0.02);
+}
+.rollout-agent.rollout-deployed { border-left: 2px solid var(--green); }
+.rollout-agent.rollout-pending { border-left: 2px solid var(--text3); opacity: 0.6; }
+.rollout-check { font-size: 14px; flex-shrink: 0; }
+.rollout-agent-info { flex: 1; }
+.rollout-agent-name { font-size: 12px; font-weight: 500; color: var(--text); }
+.rollout-agent-role { font-size: 10px; color: var(--text3); font-weight: 400; margin-left: 6px; }
+.rollout-agent-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 4px;
+}
+.rollout-hb-badge {
+ font-size: 9px;
+ padding: 1px 6px;
+ border-radius: 3px;
+}
+.rollout-hb-badge.hb-active { background: rgba(34, 197, 94, 0.1); color: var(--green); }
+.rollout-hb-badge.hb-inactive { background: rgba(255, 255, 255, 0.04); color: var(--text3); }
+.rollout-hb-time { font-size: 10px; color: var(--text3); font-family: var(--font-mono); }
+
+/* ═══════════════ CHAT DRAWER ═══════════════ */
+.chat-drawer {
+ position: fixed;
+ top: var(--topbar);
+ right: 0;
+ bottom: 0;
+ width: var(--chat-w);
+ display: flex;
+ flex-direction: column;
+ border-left: 1px solid var(--glass-border);
+ transform: translateX(100%);
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ z-index: 90;
+}
+.chat-drawer.open {
+ transform: translateX(0);
+}
+
+.chat-drawer-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--glass-border);
+ flex-shrink: 0;
+}
+.chat-drawer-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+}
+.chat-drawer-close {
+ background: none;
+ border: none;
+ color: var(--text3);
+ font-size: 16px;
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 4px;
+ transition: all 0.2s;
+}
+.chat-drawer-close:hover { color: var(--text); background: rgba(255, 255, 255, 0.06); }
+
+.chat-drawer-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* Chat sessions */
+.chat-conn-status {
+ padding: 6px 12px;
+ font-size: 11px;
+ font-weight: 500;
+ text-align: center;
+ border-radius: 4px;
+ margin: 4px 8px;
+}
+.chat-conn-info { background: rgba(0,150,255,0.15); color: #60a5fa; }
+.chat-conn-ok { background: rgba(0,200,100,0.15); color: #4ade80; }
+.chat-conn-warn { background: rgba(255,180,0,0.15); color: #fbbf24; }
+.chat-conn-error { background: rgba(255,60,60,0.15); color: #f87171; }
+
+.chat-session-bar {
+ display: flex;
+ gap: 4px;
+ padding: 8px 12px;
+ overflow-x: auto;
+ flex-shrink: 0;
+ border-bottom: 1px solid var(--glass-border);
+}
+.chat-session-tab {
+ padding: 5px 12px;
+ border: 1px solid var(--glass-border);
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--text2);
+ font-size: 11px;
+ font-weight: 500;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+}
+.chat-session-tab:hover { background: rgba(255, 255, 255, 0.06); }
+.chat-session-tab.active {
+ background: rgba(31, 78, 140, 0.25);
+ border-color: rgba(31, 78, 140, 0.4);
+ color: var(--blue-lt);
+}
+.chat-session-tab.subagent { font-size: 10px; opacity: 0.7; }
+.chat-no-sessions { font-size: 11px; color: var(--text3); padding: 4px; }
+
+/* Messages */
+.chat-messages-scroll {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px;
+}
+.chat-msg {
+ margin-bottom: 10px;
+ max-width: 92%;
+}
+.chat-msg-user {
+ margin-left: auto;
+}
+.chat-msg-user .chat-msg-content {
+ background: rgba(31, 78, 140, 0.25);
+ border: 1px solid rgba(31, 78, 140, 0.3);
+ border-radius: 12px 12px 4px 12px;
+ padding: 8px 12px;
+ font-size: 12px;
+ color: var(--text);
+ line-height: 1.5;
+}
+.chat-msg-assistant .chat-msg-content {
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--glass-border);
+ border-radius: 12px 12px 12px 4px;
+ padding: 8px 12px;
+ font-size: 12px;
+ color: var(--text2);
+ line-height: 1.5;
+}
+.chat-msg-system .chat-msg-content {
+ background: rgba(240, 140, 46, 0.08);
+ border: 1px solid rgba(240, 140, 46, 0.15);
+ border-radius: 8px;
+ padding: 6px 10px;
+ font-size: 11px;
+ color: var(--orange);
+ text-align: center;
+}
+.chat-msg-time {
+ font-size: 9px;
+ color: var(--text3);
+ margin-top: 3px;
+ padding: 0 4px;
+}
+.chat-msg-streaming {
+ opacity: 0.8;
+}
+
+.chat-cursor {
+ display: inline-block;
+ width: 2px;
+ height: 14px;
+ background: var(--blue-lt);
+ margin-left: 2px;
+ vertical-align: middle;
+ animation: blink 1s infinite;
+}
+.chat-typing { color: var(--text3); font-style: italic; }
+
+/* Input bar */
+.chat-input-bar {
+ display: flex;
+ align-items: flex-end;
+ gap: 6px;
+ padding: 10px 12px;
+ border-top: 1px solid var(--glass-border);
+ flex-shrink: 0;
+}
+.chat-input {
+ flex: 1;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--glass-border);
+ border-radius: 8px;
+ padding: 8px 12px;
+ color: var(--text);
+ font: 12px/1.4 var(--font);
+ resize: none;
+ outline: none;
+ transition: border-color 0.2s;
+}
+.chat-input:focus { border-color: rgba(31, 78, 140, 0.5); }
+.chat-input::placeholder { color: var(--text3); }
+.chat-input:disabled { opacity: 0.4; }
+
+.chat-btn {
+ width: 36px;
+ height: 36px;
+ border: 1px solid var(--glass-border);
+ border-radius: 8px;
+ background: transparent;
+ color: var(--text2);
+ font-size: 14px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s;
+ flex-shrink: 0;
+}
+.chat-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.06); color: var(--text); }
+.chat-btn:disabled { opacity: 0.3; cursor: default; }
+.chat-btn-send { color: var(--blue-lt); border-color: rgba(31, 78, 140, 0.3); }
+.chat-btn-stop { color: var(--red); border-color: rgba(214, 69, 42, 0.3); }
+
+/* ═══════════════ MODAL ═══════════════ */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+ z-index: 200;
+ display: none;
+ align-items: center;
+ justify-content: center;
+}
+.modal-overlay.visible { display: flex; }
+.modal {
+ width: 90%;
+ max-width: 700px;
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+}
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--glass-border);
+ font-size: 14px;
+ font-weight: 600;
+}
+.modal-close {
+ background: none;
+ border: none;
+ color: var(--text3);
+ font-size: 20px;
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 4px;
+}
+.modal-close:hover { color: var(--text); background: rgba(255, 255, 255, 0.06); }
+.modal-body {
+ padding: 20px;
+ overflow-y: auto;
+ font-size: 12px;
+ font-family: var(--font-mono);
+ color: var(--text);
+ line-height: 1.7;
+ white-space: pre-wrap;
+}
+.modal-body .agent-detail-card,
+.modal-body .project-form {
+ white-space: normal;
+ font-family: var(--font);
+ line-height: 1.5;
+}
+
+/* ═══════════════ SHARED STATES ═══════════════ */
+.placeholder, .empty-state {
+ text-align: center;
+ padding: 24px;
+ color: var(--text3);
+ font-size: 12px;
+}
+.empty-icon {
+ display: block;
+ font-size: 28px;
+ margin-bottom: 8px;
+ opacity: 0.5;
+}
+
+/* Disk bar */
+.disk-bar { height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.06); margin-top: 4px; overflow: hidden; }
+.disk-bar-fill { height: 100%; border-radius: 2px; background: var(--green); transition: width 0.5s; }
+.disk-bar-fill.warning { background: var(--orange); }
+.disk-bar-fill.danger { background: var(--red); }
+
+/* Status grid */
+.status-grid { display: flex; flex-direction: column; gap: 8px; }
+.status-item { display: flex; align-items: center; gap: 10px; padding: 6px 0; }
+.status-info { flex: 1; }
+.status-label { font-size: 12px; font-weight: 500; color: var(--text); }
+.status-value { font-size: 11px; color: var(--text3); display: block; }
+
+/* ═══════════════ ANIMATIONS ═══════════════ */
+@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
+@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
+
+/* ═══════════════ RESPONSIVE ═══════════════ */
+@media (max-width: 1200px) {
+ .projects-row { grid-template-columns: repeat(3, 1fr); }
+ .cron-grid { grid-template-columns: repeat(2, 1fr); }
+}
+
+@media (max-width: 960px) {
+ .content-area {
+ padding-right: 24px;
+ }
+ .chat-drawer {
+ width: 100%;
+ max-width: 380px;
+ }
+ .chat-toggle-btn { display: flex; }
+ .chat-drawer:not(.open) ~ .content-area { padding-right: 24px; }
+
+ .projects-row { grid-template-columns: repeat(2, 1fr); }
+ .metric-strip { grid-template-columns: repeat(2, 1fr); }
+ .grid-2col { grid-template-columns: 1fr; }
+ .settings-grid { grid-template-columns: 1fr; }
+ .cron-grid { grid-template-columns: 1fr; }
+
+ .topbar-nav {
+ gap: 2px;
+ }
+ .nav-tab {
+ padding: 6px 10px;
+ }
+ .nav-label { display: none; }
+}
+
+@media (max-width: 640px) {
+ :root { --topbar: 50px; --chat-w: 100%; }
+
+ .topbar { padding: 0 10px; gap: 6px; }
+ .topbar-logo { height: 28px; }
+
+ .content-area { padding: 12px; padding-right: 12px; }
+ .projects-row { grid-template-columns: 1fr; }
+ .metric-strip { grid-template-columns: repeat(2, 1fr); }
+
+ .acard { min-width: 100px; max-width: 130px; padding: 10px; }
+ .acard.coo { min-width: 140px; }
+
+ .conn-label { display: none; }
+}
+
+/* ═══════════════ SEARCH BOX ═══════════════ */
+.search-wrap {
+ position: relative;
+ margin: 16px 0 8px;
+}
+.search-icon {
+ position: absolute;
+ left: 14px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 14px;
+ pointer-events: none;
+}
+.global-search {
+ width: 100%;
+ padding: 12px 16px 12px 40px;
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--r);
+ color: var(--text);
+ font: 13px/1.4 var(--font);
+ outline: none;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+.global-search::placeholder { color: var(--text3); }
+.global-search:focus {
+ border-color: var(--glass-border-hover);
+ box-shadow: 0 0 12px rgba(90, 200, 232, 0.25);
+}
+
+/* ═══════════════ INDEXATION STATUS ═══════════════ */
+.idx-row {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 8px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+ font-size: 12px;
+}
+.idx-row:last-child { border-bottom: none; }
+.idx-source { color: var(--text); font-weight: 500; min-width: 200px; }
+.idx-link { color: rgba(90, 200, 232, 0.9); text-decoration: none; cursor: pointer; transition: color 0.2s; }
+.idx-link:hover { color: #fff; text-decoration: underline; text-shadow: 0 0 8px rgba(90, 200, 232, 0.4); }
+.idx-detail { color: var(--text3); }
+.idx-detail strong { color: var(--text2); font-weight: 500; }
+
+/* ═══════════════ CRON EXPAND/COLLAPSE ═══════════════ */
+.cron-card { cursor: pointer; }
+.cron-detail {
+ display: none;
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+ font-size: 11px;
+ color: var(--text3);
+ line-height: 1.7;
+}
+.cron-detail span { display: block; }
+.cron-card.expanded .cron-detail { display: block; }
+.cron-card .cron-expand-hint {
+ font-size: 10px;
+ color: var(--text3);
+ margin-top: 6px;
+ opacity: 0.5;
+}
+.cron-card.expanded .cron-expand-hint { display: none; }
+
+/* ═══════════════ AGENT DETAIL MODAL ═══════════════ */
+.agent-detail-card { display: flex; flex-direction: column; gap: 16px; }
+.agent-detail-header { display: flex; align-items: flex-start; gap: 16px; }
+.agent-detail-avatar { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; border: 2px solid rgba(90, 200, 232, 0.4); box-shadow: 0 0 16px rgba(90, 200, 232, 0.2); flex-shrink: 0; }
+.agent-detail-info { flex: 1; }
+.agent-detail-name { font-size: 18px; font-weight: 700; color: var(--text); font-family: var(--font); }
+.agent-detail-role { font-size: 12px; color: var(--text); margin-top: 2px; font-family: var(--font); }
+.agent-detail-section { border-top: 1px solid rgba(255, 255, 255, 0.06); padding-top: 12px; }
+.agent-detail-section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1.2px; color: var(--text2); margin-bottom: 8px; }
+.agent-detail-pre { background: rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 8px; padding: 12px; font-size: 11px; font-family: var(--font-mono); color: var(--text); line-height: 1.6; white-space: pre-wrap; max-height: 240px; overflow-y: auto; margin: 0; }
+
+/* Agent management bar */
+.agent-mgmt-bar {
+ display: flex;
+ gap: 6px;
+ padding: 8px 0;
+ border-top: 1px solid rgba(255,255,255,0.06);
+ border-bottom: 1px solid rgba(255,255,255,0.06);
+}
+
+/* SOUL editor textarea */
+.soul-editor {
+ width: 100%;
+ min-height: 300px;
+ background: rgba(0,0,0,0.3);
+ border: 1px solid var(--glass-border);
+ border-radius: 8px;
+ padding: 12px;
+ font-size: 12px;
+ font-family: var(--font-mono);
+ color: var(--text);
+ line-height: 1.6;
+ resize: vertical;
+ outline: none;
+}
+.soul-editor:focus { border-color: rgba(88,198,232,0.5); }
+
+/* Restart banner */
+.restart-banner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 14px;
+ background: rgba(245,158,11,0.12);
+ border: 1px solid rgba(245,158,11,0.3);
+ border-radius: var(--r-sm);
+ font-size: 12px;
+ color: #f59e0b;
+ margin-bottom: 12px;
+}
+
+/* ═══════════════ PANEL TIMESTAMPS ═══════════════ */
+.panel-last-updated { text-align: right; font-size: 10px; color: var(--text3, rgba(255,255,255,0.3)); padding: 6px 4px 0; opacity: 0.7; }
+
+/* ═══════════════ PROJECT BOXES CLICKABLE ═══════════════ */
+.project-box { cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; }
+.project-box:hover { transform: translateY(-2px); box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
+
+/* ═══════════════ PROJECT DETAIL MODAL ═══════════════ */
+.project-detail-desc { font-size: 13px; color: var(--text); line-height: 1.5; margin-bottom: 12px; }
+.project-detail-agent-link { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 8px; background: rgba(90, 200, 232, 0.1); border: 1px solid rgba(90, 200, 232, 0.3); color: var(--blue-lt, #5ac8e8); font-size: 12px; cursor: pointer; transition: background 0.2s; text-decoration: none; }
+.project-detail-agent-link:hover { background: rgba(90, 200, 232, 0.2); }
+.project-detail-placeholder { background: rgba(0,0,0,0.2); border: 1px dashed rgba(255,255,255,0.1); border-radius: 8px; padding: 16px; text-align: center; font-size: 11px; color: var(--text3); margin-top: 8px; }
+
+/* ═══════════════ SELECT OPTIONS (dark theme) ═══════════════ */
+select option { background: #1a1f2e; color: #e0e0e0; }
+
+/* ═══════════════ TASK ROW SHARED COMPONENT ═══════════════ */
+.task-row-shared {
+ display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px;
+ border-bottom: 1px solid rgba(255,255,255,0.04); transition: background 0.15s; cursor: pointer;
+}
+.task-row-shared:hover { background: rgba(255,255,255,0.03); }
+.task-row-shared.done { opacity: 0.5; }
+.task-row-shared .task-complete-btn {
+ width: 16px; height: 16px; border: 1.5px solid var(--text3); border-radius: 4px;
+ background: transparent; cursor: pointer; flex-shrink: 0; margin-top: 1px; transition: all 0.2s;
+ display: flex; align-items: center; justify-content: center; font-size: 10px; color: transparent; padding: 0;
+}
+.task-row-shared .task-complete-btn:hover { border-color: var(--green); color: var(--green); }
+.task-row-shared.done .task-complete-btn { background: var(--green); border-color: var(--green); color: #fff; }
+
+/* ═══════════════ PRINT ═══════════════ */
+@media print {
+ body::before { display: none; }
+ .topbar, .chat-drawer, .chat-toggle-btn { display: none !important; }
+ .content-area { padding: 0 !important; }
+ .glass-card, .glass-panel, .glass-nav { background: #fff !important; backdrop-filter: none !important; color: #000 !important; border-color: #ddd !important; }
+}
diff --git a/bates-core/plugins/dashboard/static/sw.js b/bates-core/plugins/dashboard/static/sw.js
new file mode 100644
index 0000000..869e62d
--- /dev/null
+++ b/bates-core/plugins/dashboard/static/sw.js
@@ -0,0 +1,63 @@
+/**
+ * Service Worker for Bates Command Center PWA
+ * Caches static assets for offline shell, always fetches API data fresh
+ */
+const CACHE_NAME = 'bates-dashboard-v1';
+const STATIC_ASSETS = [
+ '/dashboard/',
+ '/dashboard/styles.css',
+ '/dashboard/js/gateway.js',
+ '/dashboard/js/app.js',
+ '/dashboard/js/panel-ceo.js',
+ '/dashboard/js/panel-agents.js',
+ '/dashboard/js/panel-crons.js',
+ '/dashboard/js/panel-delegations.js',
+ '/dashboard/js/panel-files.js',
+ '/dashboard/js/panel-chat.js',
+ '/dashboard/js/panel-memory.js',
+ '/dashboard/js/panel-rollout.js',
+ '/dashboard/js/panel-status.js',
+ '/dashboard/js/panel-costs.js',
+ '/dashboard/js/panel-integrations.js',
+ '/dashboard/js/panel-settings.js',
+ '/dashboard/js/panel-standup.js',
+ '/dashboard/js/panel-tasks.js',
+];
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
+ );
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys().then((names) =>
+ Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n)))
+ )
+ );
+ self.clients.claim();
+});
+
+self.addEventListener('fetch', (event) => {
+ const url = new URL(event.request.url);
+
+ // Always fetch API routes fresh
+ if (url.pathname.includes('/api/') || url.pathname.includes('/webhook')) {
+ return;
+ }
+
+ // For static assets, try network first, fall back to cache
+ event.respondWith(
+ fetch(event.request)
+ .then((response) => {
+ if (response.ok) {
+ const clone = response.clone();
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
+ }
+ return response;
+ })
+ .catch(() => caches.match(event.request))
+ );
+});
diff --git a/bates-core/plugins/delegation-enforcer/index.ts b/bates-core/plugins/delegation-enforcer/index.ts
new file mode 100644
index 0000000..b104dc3
--- /dev/null
+++ b/bates-core/plugins/delegation-enforcer/index.ts
@@ -0,0 +1,497 @@
+import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
+import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
+import * as fs from "fs";
+import * as crypto from "crypto";
+
+// ---------------------------------------------------------------------------
+// Delegation Enforcer Plugin
+//
+// Counts tool calls for agentId="main" ACROSS ALL SESSIONS. When the count
+// exceeds TOOL_CALL_THRESHOLD without a sessions_spawn call, ALL non-spawn
+// tool calls are BLOCKED.
+//
+// Anti-circumvention: after a spawn, Bates gets a limited POST_SPAWN_ALLOWANCE
+// of additional calls (for reading results and delivering to Robert). After
+// that, it must spawn again. This prevents the "spawn trivial task, then do
+// all work in main" pattern.
+//
+// Also tracks TURN_HARD_CAP: the absolute maximum non-exempt calls per turn,
+// regardless of how many spawns happen. This prevents splitting work into
+// many small spawn-reset cycles.
+//
+// State is tracked per-agentId (not per-session) to prevent circumvention
+// via session restarts. Only resets on:
+// 1. New inbound user message (message_received)
+// 2. Time decay (DECAY_MS elapsed since last tool call)
+//
+// Sub-agents and cron sessions are unaffected.
+// ---------------------------------------------------------------------------
+
+/** Max tool calls before first spawn is required */
+const TOOL_CALL_THRESHOLD = 4;
+
+/** Max additional tool calls allowed after each spawn (for result delivery) */
+const POST_SPAWN_ALLOWANCE = 4;
+
+/** Absolute max non-exempt tool calls per turn, regardless of spawns */
+const TURN_HARD_CAP = 16;
+
+/** Auto-reset after 5 minutes of inactivity (prevents stale blocks) */
+const DECAY_MS = 5 * 60 * 1000;
+
+/** Tools that are always allowed (coordination, not work) */
+const EXEMPT_TOOLS = new Set([
+ "sessions_spawn",
+ "sessions_kill", // killing stuck sub-agent sessions
+ "sessions_list", // listing active sessions
+ "session_status", // checking session status
+ "subagents", // managing sub-agents
+ "message", // sending messages to Robert
+ "process", // reading tool results
+]);
+
+// ---------------------------------------------------------------------------
+// Self-Protection: paths that MUST NOT be modified by the main agent.
+// Blocks write/edit/exec targeting these paths. This prevents the agent from
+// disabling or modifying its own guardrails.
+// ---------------------------------------------------------------------------
+const PROTECTED_PATHS = [
+ "/home/openclaw/.openclaw/extensions/delegation-enforcer",
+ "/home/openclaw/.openclaw/openclaw.json",
+];
+
+/** Tools that can modify files or run arbitrary commands */
+const FILE_MUTATION_TOOLS = new Set(["write", "edit", "exec"]);
+
+/**
+ * Check if a tool call targets a protected path.
+ * Returns a block reason string if blocked, or null if allowed.
+ */
+function checkProtectedPaths(toolName: string, params: any): string | null {
+ if (!FILE_MUTATION_TOOLS.has(toolName)) return null;
+
+ // For write/edit: check the file_path or path parameter
+ if (toolName === "write" || toolName === "edit") {
+ const filePath = params?.file_path || params?.path || "";
+ for (const pp of PROTECTED_PATHS) {
+ if (filePath.startsWith(pp) || filePath === pp) {
+ return `[DELEGATION ENFORCER] BLOCKED: Cannot modify protected path "${filePath}". ` +
+ `The delegation enforcer and gateway config are protected from modification. ` +
+ `This is a system guardrail set by Robert.`;
+ }
+ }
+ }
+
+ // For exec: check if the command references protected paths with write intent
+ if (toolName === "exec") {
+ const command = (params?.command || params?.cmd || "").toLowerCase();
+ for (const pp of PROTECTED_PATHS) {
+ const ppLower = pp.toLowerCase();
+ const pathSegments = ppLower.split("/").filter(Boolean);
+ const lastSegment = pathSegments[pathSegments.length - 1] || "";
+
+ // Block: rm, mv, cp-over, chmod, chown, sed -i, tee, >, >> targeting protected paths
+ const dangerousPatterns = [
+ `rm ${ppLower}`, `rm -rf ${ppLower}`, `rm -f ${ppLower}`,
+ `mv ${ppLower}`, `mv `, // mv anything TO a protected path
+ `chmod `, `chown `,
+ `sed -i`, `tee ${ppLower}`, `tee -a ${ppLower}`,
+ `> ${ppLower}`, `>> ${ppLower}`,
+ // Also catch references to the enforcer directory by name
+ `delegation-enforcer`,
+ ];
+
+ // Check if command contains protected path AND a dangerous operation
+ if (command.includes(ppLower) || command.includes(lastSegment)) {
+ const hasDangerousOp = ["rm ", "rm\t", "mv ", "chmod ", "chown ", "sed -i",
+ "> ", ">> ", "tee ", "cat >", "cat >>", "echo >", "echo >>",
+ "truncate", "unlink", "shred"].some(op => command.includes(op));
+ if (hasDangerousOp) {
+ return `[DELEGATION ENFORCER] BLOCKED: Cannot execute commands targeting protected path. ` +
+ `The delegation enforcer is protected from modification via shell commands. ` +
+ `This is a system guardrail set by Robert.`;
+ }
+ }
+ }
+
+ // Also block: systemctl commands that could disable the plugin indirectly
+ // (restarting gateway is fine, but stopping it is not)
+ if (command.includes("systemctl") && command.includes("stop") && command.includes("openclaw")) {
+ return `[DELEGATION ENFORCER] BLOCKED: Cannot stop the openclaw gateway. ` +
+ `Use "systemctl --user restart" if a restart is needed.`;
+ }
+ }
+
+ return null;
+}
+
+/** Valid deputy agentIds that Bates should delegate to */
+const VALID_DEPUTIES = new Set([
+ "nova", "amara", "jules", "dash", "kira", "archer",
+ "mira", "conrad", "soren", "mercer", "paige", "quinn",
+]);
+
+/** ACP runtime agent IDs — external CLI agents, always valid for delegation */
+const ACP_AGENTS = new Set(["claude", "codex"]);
+
+/** Agent-level turn state (persists across sessions) */
+interface AgentTurnState {
+ /** Calls since last spawn (or turn start). Used for pre-spawn and post-spawn limits. */
+ callsSinceLastSpawn: number;
+ /** Total non-exempt calls this entire turn. Never resets except on new message or decay. */
+ totalCallsThisTurn: number;
+ /** Number of spawns this turn */
+ spawnCount: number;
+ lastToolCallTimestamp: number;
+ lastResetTimestamp: number;
+ /** Track which session keys have been seen -- detects session hops */
+ sessionKeysThisTurn: Set;
+}
+
+/** Keyed by agentId (not sessionKey!) */
+const agentState = new Map();
+
+function getAgentState(agentId: string): AgentTurnState {
+ let state = agentState.get(agentId);
+ if (!state) {
+ state = {
+ callsSinceLastSpawn: 0,
+ totalCallsThisTurn: 0,
+ spawnCount: 0,
+ lastToolCallTimestamp: Date.now(),
+ lastResetTimestamp: Date.now(),
+ sessionKeysThisTurn: new Set(),
+ };
+ agentState.set(agentId, state);
+ }
+ return state;
+}
+
+function resetAgentTurn(agentId: string) {
+ const state = getAgentState(agentId);
+ state.callsSinceLastSpawn = 0;
+ state.totalCallsThisTurn = 0;
+ state.spawnCount = 0;
+ state.lastToolCallTimestamp = Date.now();
+ state.lastResetTimestamp = Date.now();
+ state.sessionKeysThisTurn.clear();
+}
+
+/**
+ * Check if state should auto-decay (stale block from >5 min ago).
+ * Returns true if the state was decayed (reset).
+ */
+function checkDecay(state: AgentTurnState): boolean {
+ if ((state.callsSinceLastSpawn > 0 || state.totalCallsThisTurn > 0) &&
+ Date.now() - state.lastToolCallTimestamp > DECAY_MS) {
+ state.callsSinceLastSpawn = 0;
+ state.totalCallsThisTurn = 0;
+ state.spawnCount = 0;
+ state.lastResetTimestamp = Date.now();
+ state.sessionKeysThisTurn.clear();
+ return true;
+ }
+ return false;
+}
+
+// ---------------------------------------------------------------------------
+// Plugin
+// ---------------------------------------------------------------------------
+const plugin = {
+ id: "delegation-enforcer",
+ name: "Delegation Enforcer",
+ description: "Forces main-session delegation when tool call count exceeds threshold (session-hop resistant)",
+ configSchema: emptyPluginConfigSchema(),
+
+ register(api: OpenClawPluginApi) {
+ const log = api.logger;
+ log.info(
+ "delegation-enforcer: registered (threshold=" + TOOL_CALL_THRESHOLD +
+ ", post-spawn=" + POST_SPAWN_ALLOWANCE +
+ ", hard-cap=" + TURN_HARD_CAP +
+ ", decay=" + (DECAY_MS / 1000) + "s)"
+ );
+
+ // Reset counter when a new inbound message arrives (new turn)
+ api.on("message_received", (_event: any, _ctx: any) => {
+ // Reset agent-level state for main on new user message
+ if (agentState.has("main")) {
+ resetAgentTurn("main");
+ log.info("delegation-enforcer: reset on message_received");
+ }
+ });
+
+ // Count tool calls and enforce threshold
+ api.on("before_tool_call", (event: any, ctx: any) => {
+ const agentId = ctx.agentId;
+ const sessionKey = ctx.sessionKey || "";
+ const toolName = event.toolName;
+
+ // SELF-PROTECTION: Block modifications to protected paths from ANY agent session
+ // (main, sub-agents, cron -- nobody should modify the enforcer)
+ const protectionBlock = checkProtectedPaths(toolName, event.params);
+ if (protectionBlock) {
+ log.warn(
+ `delegation-enforcer: SELF-PROTECTION BLOCK: ${toolName} from ` +
+ `agent=${agentId} session=${sessionKey}`
+ );
+ return { block: true, blockReason: protectionBlock };
+ }
+
+ // Only enforce delegation counting on main agent, not sub-agents or crons
+ if (agentId !== "main") return undefined;
+ if (sessionKey.includes("subagent:")) return undefined;
+ if (sessionKey.includes(":cron:")) return undefined;
+
+ const state = getAgentState("main");
+
+ // Check for time-based decay
+ if (checkDecay(state)) {
+ log.info(`delegation-enforcer: state decayed after ${DECAY_MS / 1000}s inactivity [${sessionKey}]`);
+ }
+
+ // Detect session hop: if we see a new sessionKey while already blocked
+ if (!state.sessionKeysThisTurn.has(sessionKey) && state.sessionKeysThisTurn.size > 0) {
+ log.warn(
+ `delegation-enforcer: SESSION HOP detected! New sessionKey "${sessionKey}" ` +
+ `while already tracking ${state.sessionKeysThisTurn.size} session(s). ` +
+ `Tool call count carries over: segment=${state.callsSinceLastSpawn}, total=${state.totalCallsThisTurn}`
+ );
+ }
+ state.sessionKeysThisTurn.add(sessionKey);
+
+ // Exempt tools are always allowed, with extra checks for sessions_spawn
+ if (EXEMPT_TOOLS.has(toolName)) {
+ if (toolName === "sessions_spawn") {
+ const spawnAgentId = event.params?.agentId as string | undefined;
+ const spawnRuntime = event.params?.runtime as string | undefined;
+
+ // ACP runtime spawns (claude, codex) are always valid delegation
+ if (spawnRuntime === "acp") {
+ if (spawnAgentId && !ACP_AGENTS.has(spawnAgentId)) {
+ log.warn(
+ `delegation-enforcer: BLOCKING ACP spawn with unknown agentId ` +
+ `(got: ${spawnAgentId}) [${sessionKey}]`
+ );
+ return {
+ block: true,
+ blockReason:
+ `[DELEGATION ENFORCER] BLOCKED: ACP runtime only supports agentId "claude" or "codex". ` +
+ `Got: "${spawnAgentId}". Use agentId: "claude" (default) or "codex" (only when Robert asks).`,
+ };
+ }
+ state.spawnCount++;
+ state.callsSinceLastSpawn = 0; // Reset segment counter only
+ log.info(
+ `delegation-enforcer: ACP spawn #${state.spawnCount} to ${spawnAgentId || "default"}, ` +
+ `segment reset, total=${state.totalCallsThisTurn} [${sessionKey}]`
+ );
+ return undefined;
+ }
+
+ // Internal deputy spawns: enforce agentId requirement
+ if (!spawnAgentId || !VALID_DEPUTIES.has(spawnAgentId)) {
+ log.warn(
+ `delegation-enforcer: BLOCKING sessions_spawn without valid agentId ` +
+ `(got: ${spawnAgentId || "none"}) [${sessionKey}]`
+ );
+ return {
+ block: true,
+ blockReason:
+ `[DELEGATION ENFORCER] BLOCKED: sessions_spawn called without a valid agentId. ` +
+ `You MUST specify one of the pre-configured deputies: ` +
+ `nova (research), amara (writing), jules (analysis), dash (quick tasks), ` +
+ `kira (creative), archer (technical). ` +
+ `Omitting agentId wastes Opus tokens by running sub-agent work on the main model. ` +
+ `Re-call sessions_spawn with agentId set to the appropriate deputy.`,
+ };
+ }
+ state.spawnCount++;
+ state.callsSinceLastSpawn = 0; // Reset segment counter only
+ log.info(
+ `delegation-enforcer: spawn #${state.spawnCount} to ${spawnAgentId}, ` +
+ `segment reset, total=${state.totalCallsThisTurn} [${sessionKey}]`
+ );
+ }
+ return undefined;
+ }
+
+ // Increment both counters
+ state.callsSinceLastSpawn++;
+ state.totalCallsThisTurn++;
+ state.lastToolCallTimestamp = Date.now();
+
+ // Determine the current limit for this segment
+ const currentSegmentLimit = state.spawnCount === 0
+ ? TOOL_CALL_THRESHOLD // Before first spawn: 4 calls
+ : POST_SPAWN_ALLOWANCE; // After each spawn: 4 calls for delivery
+
+ // Log when approaching any limit
+ if (state.callsSinceLastSpawn >= currentSegmentLimit - 1 ||
+ state.totalCallsThisTurn >= TURN_HARD_CAP - 1) {
+ log.warn(
+ `delegation-enforcer: main agent call #${state.callsSinceLastSpawn}/${currentSegmentLimit} ` +
+ `(total: ${state.totalCallsThisTurn}/${TURN_HARD_CAP}, spawns: ${state.spawnCount}) ` +
+ `(${toolName}) [${sessionKey}]` +
+ (state.sessionKeysThisTurn.size > 1 ? ` (CROSS-SESSION)` : "")
+ );
+ }
+
+ // HARD CAP: absolute limit per turn, no matter how many spawns
+ if (state.totalCallsThisTurn > TURN_HARD_CAP) {
+ log.warn(
+ `delegation-enforcer: HARD CAP BLOCKING "${toolName}" ` +
+ `(total: ${state.totalCallsThisTurn}/${TURN_HARD_CAP}, spawns: ${state.spawnCount}) [${sessionKey}]`
+ );
+ return {
+ block: true,
+ blockReason:
+ `[DELEGATION ENFORCER] HARD CAP: You have made ${state.totalCallsThisTurn} total tool calls ` +
+ `this turn (limit: ${TURN_HARD_CAP}). This is the absolute per-turn maximum and cannot be ` +
+ `bypassed by spawning more sub-agents. You are doing too much work in the main session. ` +
+ `Send the result to Robert and stop. Further work requires a new user message.`,
+ };
+ }
+
+ // SEGMENT LIMIT: calls since last spawn (or turn start)
+ if (state.callsSinceLastSpawn > currentSegmentLimit) {
+ if (state.spawnCount === 0) {
+ // Never spawned: must delegate
+ log.warn(
+ `delegation-enforcer: BLOCKING "${toolName}" ` +
+ `(${state.callsSinceLastSpawn}/${currentSegmentLimit}, no spawn yet) [${sessionKey}]`
+ );
+ return {
+ block: true,
+ blockReason:
+ `[DELEGATION ENFORCER] BLOCKED: You have made ${state.callsSinceLastSpawn} tool calls ` +
+ `without delegating (limit: ${currentSegmentLimit}). ` +
+ `This limit is tracked ACROSS sessions -- starting a new session does NOT reset it. ` +
+ `You MUST call sessions_spawn NOW. Choose a deputy: ` +
+ `nova (research/web), amara (writing/summaries), jules (analysis), ` +
+ `dash (quick tasks), kira (creative), archer (technical). ` +
+ `Or use runtime: "acp" for Claude Code / Codex tasks. ` +
+ `Include ALL context gathered so far in the task prompt. ` +
+ `Do NOT retry the blocked tool -- it will fail again. Delegate first.`,
+ };
+ } else {
+ // Post-spawn allowance exceeded: must spawn again or stop
+ log.warn(
+ `delegation-enforcer: POST-SPAWN BLOCKING "${toolName}" ` +
+ `(${state.callsSinceLastSpawn}/${POST_SPAWN_ALLOWANCE} after spawn #${state.spawnCount}) [${sessionKey}]`
+ );
+ return {
+ block: true,
+ blockReason:
+ `[DELEGATION ENFORCER] BLOCKED: You have used ${state.callsSinceLastSpawn} tool calls ` +
+ `since your last spawn (post-spawn allowance: ${POST_SPAWN_ALLOWANCE}). ` +
+ `The post-spawn allowance is for reading results and delivering to Robert, not for ` +
+ `doing more work yourself. If you need more work done, spawn another sub-agent. ` +
+ `Total calls this turn: ${state.totalCallsThisTurn}/${TURN_HARD_CAP}.`,
+ };
+ }
+ }
+
+ return undefined;
+ });
+
+ // Inject reminder via before_prompt_build for main session only
+ api.on("before_prompt_build", (_event: any, ctx: any) => {
+ if (ctx.agentId !== "main") return undefined;
+ const sk = ctx.sessionKey || "";
+ if (sk.includes("subagent:")) return undefined;
+
+ const state = agentState.get("main");
+ if (!state) return undefined;
+
+ // Check decay before deciding to inject
+ checkDecay(state);
+
+ const segmentLimit = state.spawnCount === 0 ? TOOL_CALL_THRESHOLD : POST_SPAWN_ALLOWANCE;
+
+ // If we've been blocking (segment or hard cap), add a strong prepend
+ if (state.callsSinceLastSpawn > segmentLimit || state.totalCallsThisTurn > TURN_HARD_CAP) {
+ const atHardCap = state.totalCallsThisTurn > TURN_HARD_CAP;
+ return {
+ prependContext:
+ "\n\n[SYSTEM: DELEGATION ENFORCER ACTIVE] " +
+ (atHardCap
+ ? `HARD CAP reached (${state.totalCallsThisTurn}/${TURN_HARD_CAP} total calls). ` +
+ "You cannot make any more tool calls this turn. Deliver results to Robert and stop."
+ : `You have exceeded the ${state.spawnCount === 0 ? "pre-spawn" : "post-spawn"} ` +
+ `tool call limit (${state.callsSinceLastSpawn}/${segmentLimit}). ` +
+ (state.spawnCount === 0
+ ? "You must call sessions_spawn NOW to delegate."
+ : "Spawn another sub-agent if you need more work done, or deliver results to Robert.") +
+ ` Total: ${state.totalCallsThisTurn}/${TURN_HARD_CAP}.`) +
+ " This is enforced by the gateway, not optional.\n\n",
+ };
+ }
+ return undefined;
+ });
+
+ // NOTE: session_end does NOT reset agent-level state.
+ // This prevents circumvention via session restarts.
+ // State only resets via: message_received, sessions_spawn, or time decay.
+ api.on("session_end", (_event: any, ctx: any) => {
+ if (ctx.agentId === "main") {
+ log.info(
+ `delegation-enforcer: session_end for main (NOT resetting agent state, ` +
+ `segment=${agentState.get("main")?.callsSinceLastSpawn || 0}, ` +
+ `total=${agentState.get("main")?.totalCallsThisTurn || 0}, ` +
+ `spawns=${agentState.get("main")?.spawnCount || 0})`
+ );
+ }
+ });
+
+ // Cleanup on gateway stop (full reset is fine here -- gateway is restarting)
+ api.on("gateway_stop", () => {
+ agentState.clear();
+ if (integrityWatcher) {
+ integrityWatcher.close();
+ integrityWatcher = null;
+ }
+ log.info("delegation-enforcer: cleaned up on gateway stop");
+ });
+
+ // --- File Integrity Monitor ---
+ // Watch the plugin's own source file. If modified/deleted outside the gateway,
+ // log a critical alert. The plugin continues running from memory regardless.
+ const selfPath = "/home/openclaw/.openclaw/extensions/delegation-enforcer/index.ts";
+ let originalHash = "";
+ try {
+ const content = fs.readFileSync(selfPath, "utf8");
+ originalHash = crypto.createHash("sha256").update(content).digest("hex");
+ log.info(`delegation-enforcer: integrity baseline set (sha256: ${originalHash.slice(0, 16)}...)`);
+ } catch {
+ log.warn("delegation-enforcer: could not read self for integrity baseline");
+ }
+
+ let integrityWatcher: fs.FSWatcher | null = null;
+ try {
+ integrityWatcher = fs.watch(selfPath, (eventType) => {
+ if (eventType === "change" || eventType === "rename") {
+ let currentHash = "";
+ try {
+ const content = fs.readFileSync(selfPath, "utf8");
+ currentHash = crypto.createHash("sha256").update(content).digest("hex");
+ } catch {
+ // File deleted or unreadable
+ currentHash = "DELETED_OR_UNREADABLE";
+ }
+ if (currentHash !== originalHash) {
+ log.warn(
+ `delegation-enforcer: INTEGRITY ALERT: ${selfPath} was ${eventType}d! ` +
+ `Expected hash: ${originalHash.slice(0, 16)}..., got: ${currentHash.slice(0, 16)}... ` +
+ `Plugin continues running from memory. This may be a tampering attempt.`
+ );
+ }
+ }
+ });
+ } catch {
+ log.warn("delegation-enforcer: could not set up file watcher for integrity monitoring");
+ }
+ },
+};
+
+export default plugin;
diff --git a/bates-core/plugins/delegation-enforcer/openclaw.plugin.json b/bates-core/plugins/delegation-enforcer/openclaw.plugin.json
new file mode 100644
index 0000000..2e60a73
--- /dev/null
+++ b/bates-core/plugins/delegation-enforcer/openclaw.plugin.json
@@ -0,0 +1,10 @@
+{
+ "id": "delegation-enforcer",
+ "name": "Delegation Enforcer",
+ "description": "Blocks main-agent tool calls when count exceeds threshold without delegation, forcing sub-agent spawning",
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ }
+}
diff --git a/bates-core/plugins/m365-safety/index.ts b/bates-core/plugins/m365-safety/index.ts
new file mode 100644
index 0000000..98dbc9e
--- /dev/null
+++ b/bates-core/plugins/m365-safety/index.ts
@@ -0,0 +1,376 @@
+import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
+import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
+import * as fs from "fs";
+import * as crypto from "crypto";
+import * as net from "net";
+
+// ---------------------------------------------------------------------------
+// M365 Safety Gateway Plugin
+//
+// Layer 1 of the tamper-proof email/calendar safety system.
+//
+// Intercepts `exec` tool calls that invoke Graph API commands and:
+// 1. Rewrites graph-api.sh → graph-api-safe.sh (routes through gateway)
+// 2. Blocks direct curl to graph.microsoft.com
+// 3. Blocks mcporter WRITE operations (send-mail, create-event, etc.)
+// 4. Allows mcporter READ operations (list-mail, get-user, etc.)
+//
+// Graceful degradation: if the safety gateway is not running, graph-api.sh
+// calls are allowed through with a warning log. This prevents the plugin
+// from breaking everything when the gateway is being set up or restarted.
+//
+// Self-protection: monitors its own files for tampering and blocks tool
+// calls that target the safety infrastructure.
+//
+// KILL SWITCH: Set enforcement to "OVERRIDE_ALL_SAFETY" in plugin config
+// to disable all protection. This is a nuclear option — use only in
+// emergencies when the safety gateway is causing critical failures.
+// ---------------------------------------------------------------------------
+
+const SAFETY_SOCKET = `/run/user/${process.getuid?.() ?? 1000}/m365-safety.sock`;
+const SAFE_GRAPH_SCRIPT = "/home/openclaw/.openclaw/scripts/graph-api-safe.sh";
+
+/** The deliberately ugly config value required to disable safety */
+const KILL_SWITCH_VALUE = "OVERRIDE_ALL_SAFETY";
+
+// ---------------------------------------------------------------------------
+// Patterns
+// ---------------------------------------------------------------------------
+
+// graph-api.sh calls — rewritable to safe version
+const GRAPH_API_SH_PATTERN = /graph-api\.sh/;
+
+// Direct Graph API access — always blocked
+const DIRECT_GRAPH_PATTERNS = [
+ /curl\s[^|]*graph\.microsoft\.com/, // curl to Graph API
+ /curl\s[^|]*login\.microsoftonline\.com/, // curl to login endpoint
+];
+
+// The safe replacement — always allowed
+const SAFE_PATTERN = /graph-api-safe\.sh/;
+
+// mcporter WRITE operations — blocked (must go through gateway)
+// These are the dangerous ones: sending email, creating events, etc.
+const MCPORTER_WRITE_PATTERN =
+ /mcporter\s+call\s+ms365[^\s]*\.(send-mail|create-event|update-event|delete-event|create-message|reply-to-message|forward-message|create-todo-task|update-todo-task|delete-todo-task|create-plan-task|update-plan-task|delete-plan-task|upload-file|share-file|create-folder|delete-folder|create-subscription|send-chat-message)/;
+
+// mcporter READ operations — allowed (safe, no side effects)
+// get-current-user, list-mail-messages, get-mail-message, list-calendar-events,
+// get-calendar-event, list-todo-tasks, list-plan-tasks, search-*, etc.
+const MCPORTER_READ_PATTERN = /mcporter\s+call\s+ms365/;
+
+// Token cache direct access — blocked
+const TOKEN_CACHE_WRITE_PATTERN = /(?:cat|tee|echo|sed|cp|mv)\s[^|]*\.token-cache\.json/;
+
+// Protected paths — cannot be modified by the agent
+const PROTECTED_PATHS = [
+ "/home/openclaw/.openclaw/extensions/m365-safety",
+ "/home/openclaw/.openclaw/scripts/m365-gateway",
+ "/home/openclaw/.openclaw/m365-safety",
+ "/home/openclaw/.openclaw/scripts/graph-api-safe.sh",
+];
+
+// Tools that can modify files or run commands
+const FILE_MUTATION_TOOLS = new Set(["write", "edit", "exec"]);
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Check if a tool call targets a protected path.
+ */
+function checkProtectedPaths(toolName: string, params: any): string | null {
+ if (!FILE_MUTATION_TOOLS.has(toolName)) return null;
+
+ if (toolName === "write" || toolName === "edit") {
+ const filePath = params?.file_path || params?.path || "";
+ for (const pp of PROTECTED_PATHS) {
+ if (filePath.startsWith(pp)) {
+ return `[M365 SAFETY] BLOCKED: Cannot modify protected path "${filePath}". ` +
+ `The M365 safety gateway and its configuration are protected. ` +
+ `This is a system guardrail set by Robert.`;
+ }
+ }
+ }
+
+ if (toolName === "exec") {
+ const command = (params?.command || params?.cmd || "").toLowerCase();
+ for (const pp of PROTECTED_PATHS) {
+ const ppLower = pp.toLowerCase();
+ if (command.includes(ppLower)) {
+ const hasDangerousOp = [
+ "rm ", "rm\t", "mv ", "chmod ", "chown ", "sed -i",
+ "> ", ">> ", "tee ", "cat >", "echo >",
+ "truncate", "unlink", "shred", "kill",
+ ].some(op => command.includes(op));
+ if (hasDangerousOp) {
+ return `[M365 SAFETY] BLOCKED: Cannot execute commands targeting M365 safety infrastructure. ` +
+ `This is a system guardrail set by Robert.`;
+ }
+ }
+ }
+
+ if (command.includes("systemctl") && command.includes("stop") && command.includes("m365-safety")) {
+ return `[M365 SAFETY] BLOCKED: Cannot stop the M365 safety gateway service.`;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Rewrite graph-api.sh to graph-api-safe.sh (same args).
+ */
+function rewriteToSafeCommand(command: string): string | null {
+ const match = command.match(
+ /((?:~\/\.openclaw\/scripts\/|\/home\/openclaw\/\.openclaw\/scripts\/)?)graph-api\.sh(\s+.*)/i
+ );
+ if (match) {
+ return `${SAFE_GRAPH_SCRIPT}${match[2]}`;
+ }
+ return null;
+}
+
+/**
+ * Check if the safety gateway socket exists (fast sync check).
+ * Does NOT do a health check — just checks the socket file.
+ */
+function isGatewaySocketPresent(): boolean {
+ try {
+ const stat = fs.statSync(SAFETY_SOCKET);
+ return stat.isSocket?.() ?? false;
+ } catch {
+ return false;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Plugin
+// ---------------------------------------------------------------------------
+const plugin = {
+ id: "m365-safety",
+ name: "M365 Safety Gateway",
+ description: "Enforces tamper-proof M365 API access via safety gateway proxy",
+ configSchema: emptyPluginConfigSchema(),
+
+ register(api: OpenClawPluginApi) {
+ const log = api.logger;
+ const pluginConfig = (api as any).config ?? {};
+ const enforcement = pluginConfig.enforcement ?? "active";
+ const killSwitchActive = enforcement === KILL_SWITCH_VALUE;
+
+ if (killSwitchActive) {
+ log.warn("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
+ log.warn("!! !!");
+ log.warn("!! M365 SAFETY GATEWAY — ALL PROTECTION DISABLED !!");
+ log.warn("!! !!");
+ log.warn("!! Email whitelisting: OFF !!");
+ log.warn("!! Calendar protection: OFF !!");
+ log.warn("!! Graph API interception: OFF !!");
+ log.warn("!! Self-protection: OFF !!");
+ log.warn("!! Audit logging: OFF !!");
+ log.warn("!! !!");
+ log.warn("!! The agent has UNRESTRICTED access to Microsoft 365. !!");
+ log.warn("!! It can send emails to anyone, modify any calendar !!");
+ log.warn("!! event, and access any Graph API endpoint without !!");
+ log.warn("!! whitelist checks. !!");
+ log.warn("!! !!");
+ log.warn("!! To restore protection: !!");
+ log.warn("!! Set plugins.entries.m365-safety.config.enforcement !!");
+ log.warn("!! back to \"active\" in openclaw.json and restart. !!");
+ log.warn("!! !!");
+ log.warn("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
+
+ // Repeat every 10 minutes so it stays visible in logs
+ const reminderInterval = setInterval(() => {
+ log.warn(
+ "!! M365 SAFETY OVERRIDE STILL ACTIVE — All email/calendar " +
+ "protection is DISABLED. Set enforcement: \"active\" to restore. !!"
+ );
+ }, 10 * 60 * 1000);
+
+ api.on("gateway_stop", () => clearInterval(reminderInterval));
+
+ // Still inject a warning into the agent's prompt so Bates knows
+ api.on("before_prompt_build", (_event: any, ctx: any) => {
+ if (ctx.agentId !== "main") return undefined;
+ const sk = ctx.sessionKey || "";
+ if (sk.includes("subagent:")) return undefined;
+ return {
+ prependContext:
+ "\n[CRITICAL WARNING: M365 SAFETY GATEWAY IS DISABLED] " +
+ "All email, calendar, and Graph API protections are currently OFF. " +
+ "You have unrestricted access to Microsoft 365. Exercise EXTREME caution " +
+ "with any write operations. Double-check all email recipients manually. " +
+ "Tell Robert that safety is disabled if he is not already aware.\n",
+ };
+ });
+
+ log.warn("m365-safety: plugin loaded in OVERRIDE mode — no enforcement hooks registered");
+ return;
+ }
+
+ log.info("m365-safety: registered — intercepting Graph API calls");
+
+ // Intercept tool calls
+ api.on("before_tool_call", (event: any, ctx: any) => {
+ const toolName = event.toolName;
+ const params = event.params || {};
+
+ // Self-protection: block modifications to protected paths from ANY agent
+ const protectionBlock = checkProtectedPaths(toolName, params);
+ if (protectionBlock) {
+ log.warn(
+ `m365-safety: SELF-PROTECTION BLOCK: ${toolName} from ` +
+ `agent=${ctx.agentId} session=${ctx.sessionKey}`
+ );
+ return { block: true, blockReason: protectionBlock };
+ }
+
+ // Only intercept exec calls from here on
+ if (toolName !== "exec") return undefined;
+
+ const command: string = params.command || params.cmd || "";
+ if (!command) return undefined;
+
+ // --- SAFE SCRIPT: always allow ---
+ if (SAFE_PATTERN.test(command)) return undefined;
+
+ // --- GRAPH-API.SH: rewrite to safe version ---
+ if (GRAPH_API_SH_PATTERN.test(command)) {
+ const safeCommand = rewriteToSafeCommand(command);
+ if (safeCommand) {
+ // Check if gateway is actually running
+ if (isGatewaySocketPresent()) {
+ log.info(
+ `m365-safety: REWRITING graph-api.sh → safe gateway ` +
+ `(agent=${ctx.agentId}): "${command.slice(0, 80)}..."`
+ );
+ event.params.command = safeCommand;
+ if (event.params.cmd) event.params.cmd = safeCommand;
+ return undefined;
+ } else {
+ // GRACEFUL DEGRADATION: gateway not running, allow original command
+ log.warn(
+ `m365-safety: PASSTHROUGH (gateway not running): "${command.slice(0, 80)}..." ` +
+ `from agent=${ctx.agentId}. Start the gateway to enforce safety.`
+ );
+ return undefined;
+ }
+ }
+ }
+
+ // --- DIRECT CURL to Graph/Login endpoints: block ---
+ for (const pattern of DIRECT_GRAPH_PATTERNS) {
+ if (pattern.test(command)) {
+ log.warn(
+ `m365-safety: BLOCKING direct Graph API curl from agent=${ctx.agentId}: ` +
+ `"${command.slice(0, 120)}..."`
+ );
+ return {
+ block: true,
+ blockReason:
+ `[M365 SAFETY] BLOCKED: Direct curl to Microsoft Graph API is not allowed. ` +
+ `Use graph-api-safe.sh which routes through the safety gateway. ` +
+ `This is a tamper-proof safety measure set by Robert.`,
+ };
+ }
+ }
+
+ // --- MCPORTER WRITE operations: block ---
+ if (MCPORTER_WRITE_PATTERN.test(command)) {
+ log.warn(
+ `m365-safety: BLOCKING mcporter write operation from agent=${ctx.agentId}: ` +
+ `"${command.slice(0, 120)}..."`
+ );
+ return {
+ block: true,
+ blockReason:
+ `[M365 SAFETY] BLOCKED: This mcporter write operation must go through the ` +
+ `M365 safety gateway. Use graph-api-safe.sh for write operations (POST/PUT/PATCH/DELETE). ` +
+ `Read operations via mcporter are allowed.`,
+ };
+ }
+
+ // --- MCPORTER READ operations: allow ---
+ // (Matched by MCPORTER_READ_PATTERN but NOT by MCPORTER_WRITE_PATTERN → safe)
+
+ // --- TOKEN CACHE writes: block ---
+ if (TOKEN_CACHE_WRITE_PATTERN.test(command)) {
+ log.warn(
+ `m365-safety: BLOCKING token cache modification from agent=${ctx.agentId}`
+ );
+ return {
+ block: true,
+ blockReason:
+ `[M365 SAFETY] BLOCKED: Cannot modify the OAuth token cache directly. ` +
+ `Token management is handled by the M365 safety gateway.`,
+ };
+ }
+
+ return undefined;
+ });
+
+ // Inject context about safety gateway into main agent prompts
+ api.on("before_prompt_build", (_event: any, ctx: any) => {
+ if (ctx.agentId !== "main") return undefined;
+ const sk = ctx.sessionKey || "";
+ if (sk.includes("subagent:")) return undefined;
+
+ return {
+ prependContext:
+ "\n[SYSTEM: M365 SAFETY GATEWAY ACTIVE] All Microsoft Graph API write " +
+ "operations are routed through the tamper-proof safety gateway. " +
+ "Use graph-api-safe.sh instead of graph-api.sh for any Graph API calls. " +
+ "Direct curl to graph.microsoft.com and mcporter write operations are blocked. " +
+ "Read operations via mcporter are allowed.\n",
+ };
+ });
+
+ // --- File Integrity Monitor ---
+ const selfPath = "/home/openclaw/.openclaw/extensions/m365-safety/index.ts";
+ let originalHash = "";
+ try {
+ const content = fs.readFileSync(selfPath, "utf8");
+ originalHash = crypto.createHash("sha256").update(content).digest("hex");
+ log.info(`m365-safety: integrity baseline (sha256: ${originalHash.slice(0, 16)}...)`);
+ } catch {
+ log.warn("m365-safety: could not read self for integrity baseline");
+ }
+
+ let integrityWatcher: fs.FSWatcher | null = null;
+ try {
+ integrityWatcher = fs.watch(selfPath, (eventType) => {
+ if (eventType === "change" || eventType === "rename") {
+ let currentHash = "";
+ try {
+ const content = fs.readFileSync(selfPath, "utf8");
+ currentHash = crypto.createHash("sha256").update(content).digest("hex");
+ } catch {
+ currentHash = "DELETED_OR_UNREADABLE";
+ }
+ if (currentHash !== originalHash) {
+ log.warn(
+ `m365-safety: INTEGRITY ALERT: ${selfPath} was ${eventType}d! ` +
+ `Expected: ${originalHash.slice(0, 16)}..., got: ${currentHash.slice(0, 16)}... ` +
+ `Plugin continues from memory.`
+ );
+ }
+ }
+ });
+ } catch {
+ log.warn("m365-safety: could not set up file watcher");
+ }
+
+ api.on("gateway_stop", () => {
+ if (integrityWatcher) {
+ integrityWatcher.close();
+ integrityWatcher = null;
+ }
+ log.info("m365-safety: cleaned up on gateway stop");
+ });
+ },
+};
+
+export default plugin;
diff --git a/bates-core/plugins/m365-safety/openclaw.plugin.json b/bates-core/plugins/m365-safety/openclaw.plugin.json
new file mode 100644
index 0000000..656770d
--- /dev/null
+++ b/bates-core/plugins/m365-safety/openclaw.plugin.json
@@ -0,0 +1,17 @@
+{
+ "id": "m365-safety",
+ "name": "M365 Safety Gateway",
+ "description": "Intercepts Graph API tool calls and routes them through the tamper-proof M365 safety gateway process",
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enforcement": {
+ "type": "string",
+ "enum": ["active", "OVERRIDE_ALL_SAFETY"],
+ "default": "active",
+ "description": "Safety enforcement mode. 'active' = normal operation. 'OVERRIDE_ALL_SAFETY' = DISABLES ALL PROTECTION. Only use in emergencies."
+ }
+ }
+ }
+}
diff --git a/bates-core/plugins/session-continuity/index.ts b/bates-core/plugins/session-continuity/index.ts
new file mode 100644
index 0000000..3792aa1
--- /dev/null
+++ b/bates-core/plugins/session-continuity/index.ts
@@ -0,0 +1,542 @@
+import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
+import { join, dirname } from "path";
+import { fileURLToPath } from "url";
+import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
+import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
+const DATA_DIR = join(PLUGIN_DIR, "data");
+const DIGESTS_DIR = join(DATA_DIR, "digests");
+
+/** Max age for digest injection (ms). Stale digests are skipped. */
+const MAX_DIGEST_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
+
+/** Rolling buffer size for interaction summaries */
+const MAX_INTERACTIONS = 10;
+
+/** Max chars to extract from a message for summarization */
+const SUMMARY_MAX_CHARS = 300;
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+interface InteractionSummary {
+ role: "robert" | "bates" | "deputy" | "system";
+ summary: string;
+ timestamp: string;
+}
+
+interface HandoffDigest {
+ sessionKey: string;
+ sessionId?: string;
+ timestamp: string;
+ reason: string;
+ lastInteractions: InteractionSummary[];
+ activeTasks: string[];
+ pendingDecisions: string[];
+ recentDeliveries: string[];
+ /** File paths and artifacts mentioned in recent messages */
+ recentArtifacts: string[];
+}
+
+// ---------------------------------------------------------------------------
+// In-memory state
+// ---------------------------------------------------------------------------
+
+/** Per-session rolling digest (keyed by sessionKey or agentId) */
+const sessionDigests = new Map();
+
+/** Track which digests have been consumed (injected) to avoid re-injection.
+ * Keyed by sessionKey, value is the digest timestamp that was injected. */
+const consumedDigests = new Map();
+
+// ---------------------------------------------------------------------------
+// Helpers: File I/O
+// ---------------------------------------------------------------------------
+function ensureDataDir(): void {
+ if (!existsSync(DIGESTS_DIR)) {
+ mkdirSync(DIGESTS_DIR, { recursive: true });
+ }
+}
+
+function digestPath(agentId: string): string {
+ // Sanitize agentId for filesystem
+ const safe = agentId.replace(/[^a-zA-Z0-9_-]/g, "_");
+ return join(DIGESTS_DIR, `${safe}.json`);
+}
+
+function loadDigest(agentId: string): HandoffDigest | null {
+ const path = digestPath(agentId);
+ if (!existsSync(path)) return null;
+ try {
+ return JSON.parse(readFileSync(path, "utf-8"));
+ } catch {
+ return null;
+ }
+}
+
+function saveDigest(agentId: string, digest: HandoffDigest): void {
+ ensureDataDir();
+ writeFileSync(digestPath(agentId), JSON.stringify(digest, null, 2));
+}
+
+// ---------------------------------------------------------------------------
+// Helpers: Message summarization (rule-based, no LLM)
+// ---------------------------------------------------------------------------
+
+/** Extract text content from a message object. Messages can have various shapes. */
+function extractText(msg: any): string {
+ if (!msg) return "";
+ // String content
+ if (typeof msg.content === "string") return msg.content;
+ // Array of content blocks (Anthropic format)
+ if (Array.isArray(msg.content)) {
+ const textBlocks = msg.content
+ .filter((b: any) => b.type === "text" && b.text)
+ .map((b: any) => b.text);
+ return textBlocks.join(" ");
+ }
+ // Direct text field
+ if (typeof msg.text === "string") return msg.text;
+ return "";
+}
+
+/** Truncate text at a sentence boundary near maxLen */
+function truncateAtSentence(text: string, maxLen: number): string {
+ if (text.length <= maxLen) return text;
+ const truncated = text.slice(0, maxLen);
+ // Try to cut at last sentence boundary
+ const lastPeriod = truncated.lastIndexOf(". ");
+ const lastQuestion = truncated.lastIndexOf("? ");
+ const lastExclaim = truncated.lastIndexOf("! ");
+ const bestCut = Math.max(lastPeriod, lastQuestion, lastExclaim);
+ if (bestCut > maxLen * 0.4) {
+ return truncated.slice(0, bestCut + 1);
+ }
+ return truncated + "...";
+}
+
+/** Strip injected session-handoff blocks from text to prevent recursive nesting */
+function stripHandoffBlocks(text: string): string {
+ // Remove ... blocks (possibly nested)
+ let cleaned = text.replace(/[\s\S]*?<\/session-handoff>/g, "").trim();
+ // Also strip orphaned opening tags (in case closing tag was truncated)
+ cleaned = cleaned.replace(/[\s\S]*/g, "").trim();
+ return cleaned;
+}
+
+/** Summarize a user (Robert) message */
+function summarizeUserMessage(msg: any): string {
+ const text = stripHandoffBlocks(extractText(msg).trim());
+ if (!text) return "(empty message)";
+ return "Robert: " + truncateAtSentence(text, SUMMARY_MAX_CHARS);
+}
+
+/** Summarize an assistant (Bates) message, looking for structured patterns */
+function summarizeAssistantMessage(msg: any): string {
+ const text = extractText(msg).trim();
+ if (!text) return "(tool-only turn)";
+
+ // Check for task closure protocol
+ const statusMatch = text.match(/STATUS:\s*(DONE|NOT_DONE)/);
+ if (statusMatch) {
+ const artifactMatch = text.match(/ARTIFACT:\s*(.+)/);
+ const summary = `Bates: ${statusMatch[0]}`;
+ return artifactMatch ? `${summary}, ${artifactMatch[0]}` : summary;
+ }
+
+ // Check for delegation pattern
+ const delegateMatch = text.match(/(?:Delegating|Spawning|dispatching)\s+(?:to\s+)?(\w+)/i);
+ if (delegateMatch) {
+ return `Bates: Delegated to ${delegateMatch[1]}. ${truncateAtSentence(text, 80)}`;
+ }
+
+ // Check for sub-agent result delivery
+ const deputyMatch = text.match(/(?:Baby Bates|Deputy|Sub-agent)\s*(?:result|report)?:?\s*/i);
+ if (deputyMatch) {
+ return `Bates delivered deputy result. ${truncateAtSentence(text.slice(deputyMatch.index! + deputyMatch[0].length), 100)}`;
+ }
+
+ return "Bates: " + truncateAtSentence(text, SUMMARY_MAX_CHARS);
+}
+
+/** Detect active tasks from message content */
+function detectActiveTasks(text: string): string[] {
+ const tasks: string[] = [];
+ // Look for "working on" / "investigating" / "looking into" patterns
+ const workingOn = text.match(/(?:working on|investigating|looking into|tackling)\s+(.{10,80}?)(?:\.|$)/gi);
+ if (workingOn) {
+ for (const match of workingOn) {
+ tasks.push(truncateAtSentence(match, 80));
+ }
+ }
+ return tasks;
+}
+
+/** Detect pending decisions from message content */
+function detectPendingDecisions(text: string): string[] {
+ const decisions: string[] = [];
+ // Questions from Bates to Robert
+ const questions = text.match(/(?:shall I|should I|would you like|do you want|which option)\s+(.{10,80}?\?)/gi);
+ if (questions) {
+ for (const q of questions) {
+ decisions.push(truncateAtSentence(q, 80));
+ }
+ }
+ return decisions;
+}
+
+/** Detect file paths and artifacts mentioned in messages */
+function detectArtifacts(text: string): string[] {
+ const artifacts: string[] = [];
+ const seen = new Set();
+
+ // File paths (Unix-style)
+ const pathMatches = text.matchAll(/(?:\/[\w._-]+){2,}(?:\.\w+)?/g);
+ for (const m of pathMatches) {
+ const p = m[0];
+ // Skip common false positives
+ if (p.startsWith("/v1/") || p.startsWith("/api/") || p.startsWith("/me/")) continue;
+ if (!seen.has(p)) {
+ seen.add(p);
+ artifacts.push(`file: ${p}`);
+ }
+ }
+
+ // OneDrive paths (drafts/...)
+ const odMatches = text.matchAll(/drafts\/[\w._\-/]+/g);
+ for (const m of odMatches) {
+ if (!seen.has(m[0])) {
+ seen.add(m[0]);
+ artifacts.push(`onedrive: ${m[0]}`);
+ }
+ }
+
+ // URLs (uploaded files, shared links)
+ const urlMatches = text.matchAll(/https?:\/\/[^\s"'<>)\]]+/g);
+ for (const m of urlMatches) {
+ const url = m[0];
+ // Only capture OneDrive/SharePoint/Teams URLs
+ if (url.includes("sharepoint") || url.includes("onedrive") || url.includes("teams.microsoft")) {
+ if (!seen.has(url)) {
+ seen.add(url);
+ artifacts.push(`url: ${truncateAtSentence(url, 200)}`);
+ }
+ }
+ }
+
+ // "saved to" / "uploaded to" / "posted to" patterns
+ const savedMatches = text.matchAll(/(?:saved|uploaded|posted|written|sent|delivered)\s+(?:to|in|at)\s+([^\n.]{10,100})/gi);
+ for (const m of savedMatches) {
+ const target = m[1].trim();
+ if (!seen.has(target)) {
+ seen.add(target);
+ artifacts.push(`target: ${target}`);
+ }
+ }
+
+ return artifacts.slice(0, 10); // Cap at 10
+}
+
+/** Detect cron/deputy deliveries */
+function detectDeliveries(text: string): string[] {
+ const deliveries: string[] = [];
+ const cronMatch = text.match(/(?:cron|scheduled|heartbeat).*?(?:result|report|update):?\s*(.{10,60})/gi);
+ if (cronMatch) {
+ for (const m of cronMatch) {
+ deliveries.push(truncateAtSentence(m, 60));
+ }
+ }
+ return deliveries;
+}
+
+// ---------------------------------------------------------------------------
+// Core: Update digest from messages
+// ---------------------------------------------------------------------------
+function updateDigestFromMessages(
+ agentId: string,
+ sessionKey: string,
+ messages: any[],
+ reason?: string,
+ sessionId?: string
+): HandoffDigest {
+ const existing = sessionDigests.get(agentId) || loadDigest(agentId) || {
+ sessionKey,
+ timestamp: new Date().toISOString(),
+ reason: reason || "agent_end",
+ lastInteractions: [],
+ activeTasks: [],
+ pendingDecisions: [],
+ recentDeliveries: [],
+ recentArtifacts: [],
+ };
+
+ // Ensure recentArtifacts exists on loaded digests
+ if (!existing.recentArtifacts) existing.recentArtifacts = [];
+
+ // Find last user and assistant messages
+ const userMsgs = messages.filter((m: any) => m.role === "user" || m.role === "human");
+ const assistantMsgs = messages.filter((m: any) => m.role === "assistant");
+
+ const lastUser = userMsgs.length > 0 ? userMsgs[userMsgs.length - 1] : null;
+ const lastAssistant = assistantMsgs.length > 0 ? assistantMsgs[assistantMsgs.length - 1] : null;
+
+ const now = new Date().toISOString();
+
+ // Add user interaction if present
+ if (lastUser) {
+ const userText = stripHandoffBlocks(extractText(lastUser).trim());
+ // Skip system/tool-only messages and pure handoff injections
+ if (userText && !userText.startsWith("[Tool:") && !userText.startsWith(" 0) {
+ existing.recentArtifacts.push(...userArtifacts);
+ existing.recentArtifacts = [...new Set(existing.recentArtifacts)].slice(-10);
+ }
+ }
+ }
+
+ // Add assistant interaction if present
+ if (lastAssistant) {
+ const assistantText = extractText(lastAssistant).trim();
+ if (assistantText) {
+ existing.lastInteractions.push({
+ role: "bates",
+ summary: summarizeAssistantMessage(lastAssistant),
+ timestamp: now,
+ });
+
+ // Scan for task/decision/delivery/artifact patterns
+ const newTasks = detectActiveTasks(assistantText);
+ const newDecisions = detectPendingDecisions(assistantText);
+ const newDeliveries = detectDeliveries(assistantText);
+ const newArtifacts = detectArtifacts(assistantText);
+
+ if (newTasks.length > 0) existing.activeTasks = newTasks;
+ if (newDecisions.length > 0) existing.pendingDecisions = newDecisions;
+ if (newDeliveries.length > 0) {
+ existing.recentDeliveries.push(...newDeliveries);
+ if (existing.recentDeliveries.length > 5) {
+ existing.recentDeliveries = existing.recentDeliveries.slice(-5);
+ }
+ }
+ if (newArtifacts.length > 0) {
+ existing.recentArtifacts.push(...newArtifacts);
+ // Deduplicate and keep last 10
+ existing.recentArtifacts = [...new Set(existing.recentArtifacts)].slice(-10);
+ }
+ }
+ }
+
+ // Trim rolling buffer
+ if (existing.lastInteractions.length > MAX_INTERACTIONS) {
+ existing.lastInteractions = existing.lastInteractions.slice(-MAX_INTERACTIONS);
+ }
+
+ // Update metadata
+ existing.sessionKey = sessionKey;
+ if (sessionId) existing.sessionId = sessionId;
+ existing.timestamp = now;
+ if (reason) existing.reason = reason;
+
+ // Persist
+ sessionDigests.set(agentId, existing);
+ saveDigest(agentId, existing);
+
+ return existing;
+}
+
+// ---------------------------------------------------------------------------
+// Core: Format digest for injection
+// ---------------------------------------------------------------------------
+function formatDigestForInjection(digest: HandoffDigest): string {
+ const age = Date.now() - new Date(digest.timestamp).getTime();
+ const ageMinutes = Math.round(age / 60000);
+ const ageStr = ageMinutes < 60
+ ? `${ageMinutes} minute${ageMinutes !== 1 ? "s" : ""} ago`
+ : `${Math.round(ageMinutes / 60)} hour${Math.round(ageMinutes / 60) !== 1 ? "s" : ""} ago`;
+
+ let reasonStr = digest.reason;
+ if (reasonStr === "idle") reasonStr = "idle timeout";
+ if (reasonStr === "overflow") reasonStr = "context overflow";
+ if (reasonStr === "reset_command") reasonStr = "manual reset";
+
+ const lines: string[] = [
+ "",
+ `Your previous session ended ${ageStr} (reason: ${reasonStr}).`,
+ "",
+ ];
+
+ if (digest.lastInteractions.length > 0) {
+ lines.push("Last interactions:");
+ for (const interaction of digest.lastInteractions) {
+ lines.push(`- ${interaction.summary}`);
+ }
+ lines.push("");
+ }
+
+ if (digest.activeTasks.length > 0) {
+ lines.push(`Active tasks: ${digest.activeTasks.join("; ")}`);
+ }
+ if (digest.pendingDecisions.length > 0) {
+ lines.push(`Pending decisions: ${digest.pendingDecisions.join("; ")}`);
+ }
+ if (digest.recentDeliveries.length > 0) {
+ lines.push(`Recent deliveries: ${digest.recentDeliveries.join("; ")}`);
+ }
+ if (digest.recentArtifacts && digest.recentArtifacts.length > 0) {
+ lines.push("");
+ lines.push("Files/artifacts from recent work:");
+ for (const artifact of digest.recentArtifacts) {
+ lines.push(`- ${artifact}`);
+ }
+ }
+
+ if (
+ digest.activeTasks.length === 0 &&
+ digest.pendingDecisions.length === 0 &&
+ digest.recentDeliveries.length === 0 &&
+ (!digest.recentArtifacts || digest.recentArtifacts.length === 0)
+ ) {
+ lines.push("No active tasks, pending decisions, deliveries, or artifacts.");
+ }
+
+ lines.push("");
+ return lines.join("\n");
+}
+
+// ---------------------------------------------------------------------------
+// Plugin definition
+// ---------------------------------------------------------------------------
+const plugin = {
+ id: "session-continuity",
+ name: "Session Continuity",
+ description: "Persists conversational context across session resets via handoff digests",
+ configSchema: emptyPluginConfigSchema(),
+
+ register(api: OpenClawPluginApi) {
+ const log = api.logger;
+ ensureDataDir();
+
+ log.info("session-continuity: plugin registered");
+
+ // -------------------------------------------------------------------
+ // 1. agent_end: Update rolling digest after each agent turn
+ // -------------------------------------------------------------------
+ api.on("agent_end", (event: any, ctx: any) => {
+ try {
+ const agentId = ctx.agentId || "main";
+ const sessionKey = ctx.sessionKey || "unknown";
+ const sessionId = ctx.sessionId || undefined;
+ const messages = event.messages || [];
+
+ if (messages.length === 0) return;
+
+ updateDigestFromMessages(agentId, sessionKey, messages, "agent_end", sessionId);
+ log.info(`session-continuity: digest updated for ${agentId} (${messages.length} msgs)`);
+ } catch (err: any) {
+ log.error(`session-continuity: agent_end error: ${err.message}`);
+ }
+ });
+
+ // -------------------------------------------------------------------
+ // 2. before_compaction: Snapshot digest before messages are pruned
+ // -------------------------------------------------------------------
+ api.on("before_compaction", (event: any, ctx: any) => {
+ try {
+ const agentId = ctx.agentId || "main";
+ const sessionKey = ctx.sessionKey || "unknown";
+ const sessionId = ctx.sessionId || undefined;
+ const messages = event.messages || [];
+
+ if (messages.length === 0) return;
+
+ updateDigestFromMessages(agentId, sessionKey, messages, "compaction", sessionId);
+ log.info(`session-continuity: pre-compaction digest saved for ${agentId} (${event.compactingCount} msgs being compacted)`);
+ } catch (err: any) {
+ log.error(`session-continuity: before_compaction error: ${err.message}`);
+ }
+ });
+
+ // -------------------------------------------------------------------
+ // 3. before_reset: Write final handoff digest
+ // -------------------------------------------------------------------
+ api.on("before_reset", (event: any, ctx: any) => {
+ try {
+ const agentId = ctx.agentId || "main";
+ const sessionKey = ctx.sessionKey || "unknown";
+ const sessionId = ctx.sessionId || undefined;
+ const messages = event.messages || [];
+ const reason = event.reason || "unknown";
+
+ updateDigestFromMessages(agentId, sessionKey, messages, reason, sessionId);
+ log.info(`session-continuity: handoff digest written for ${agentId} (reason: ${reason})`);
+ } catch (err: any) {
+ log.error(`session-continuity: before_reset error: ${err.message}`);
+ }
+ });
+
+ // -------------------------------------------------------------------
+ // 4. before_prompt_build: Inject handoff digest into new sessions
+ // -------------------------------------------------------------------
+ api.on("before_prompt_build", (_event: any, ctx: any) => {
+ try {
+ const agentId = ctx.agentId || "main";
+ const sessionKey = ctx.sessionKey || "unknown";
+ const sessionId = ctx.sessionId || "unknown";
+
+ // Check if we already injected for this session (use sessionId for uniqueness)
+ const consumed = consumedDigests.get(sessionId);
+ const digest = loadDigest(agentId);
+
+ if (!digest) return;
+
+ // Skip if already consumed for this session
+ if (consumed === digest.timestamp) return;
+
+ // Skip stale digests
+ const age = Date.now() - new Date(digest.timestamp).getTime();
+ if (age > MAX_DIGEST_AGE_MS) {
+ log.info(`session-continuity: digest for ${agentId} is stale (${Math.round(age / 60000)}min), skipping`);
+ return;
+ }
+
+ // Skip if this is the same session (by sessionId) that wrote the digest.
+ // NOTE: sessionKey (e.g. "agent:main:main") is NOT unique per session --
+ // it's a fixed routing key. Use sessionId (UUID) for dedup.
+ if (digest.sessionId && digest.sessionId === sessionId) return;
+
+ // Format and inject
+ const formatted = formatDigestForInjection(digest);
+ consumedDigests.set(sessionId, digest.timestamp);
+
+ log.info(`session-continuity: injecting handoff digest for ${agentId} into session ${sessionId} (digestSessionId=${digest.sessionId || "none"}, age=${Math.round(age / 60000)}min)`);
+
+ return { prependContext: formatted };
+ } catch (err: any) {
+ log.error(`session-continuity: before_prompt_build error: ${err.message}`);
+ return undefined;
+ }
+ });
+
+ // -------------------------------------------------------------------
+ // 5. gateway_stop: Clean up in-memory state
+ // -------------------------------------------------------------------
+ api.on("gateway_stop", () => {
+ sessionDigests.clear();
+ consumedDigests.clear();
+ log.info("session-continuity: cleaned up on gateway stop");
+ });
+ },
+};
+
+export default plugin;
diff --git a/bates-core/plugins/session-continuity/openclaw.plugin.json b/bates-core/plugins/session-continuity/openclaw.plugin.json
new file mode 100644
index 0000000..acbd01b
--- /dev/null
+++ b/bates-core/plugins/session-continuity/openclaw.plugin.json
@@ -0,0 +1,10 @@
+{
+ "id": "session-continuity",
+ "name": "Session Continuity",
+ "description": "Persists conversational context across session resets via handoff digests",
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ }
+}
diff --git a/bates-core/scripts-core/acp-health-check.sh b/bates-core/scripts-core/acp-health-check.sh
new file mode 100755
index 0000000..fa3c0ff
--- /dev/null
+++ b/bates-core/scripts-core/acp-health-check.sh
@@ -0,0 +1,144 @@
+#!/usr/bin/env bash
+# acp-health-check.sh — Check ACP runtime health and attempt self-repair
+#
+# Usage: acp-health-check.sh [--repair] [--help]
+#
+# Checks:
+# 1. Gateway service status
+# 2. acpx plugin presence in recent gateway logs
+# 3. Concurrent session count vs. maxConcurrentSessions (3)
+#
+# With --repair:
+# Attempts npm install to repair the acpx plugin
+#
+# Outputs JSON:
+# {
+# "status": "healthy|degraded|unhealthy",
+# "gateway_active": true|false,
+# "acpx_seen": true|false,
+# "repair_attempted": false,
+# "repair_success": null,
+# "recommendation": "...",
+# "fallback": "~/.openclaw/scripts/run-delegation.sh"
+# }
+#
+# Exit codes:
+# 0 ACP healthy
+# 1 ACP degraded or unhealthy
+# 2 Gateway not running
+
+set -euo pipefail
+
+REPAIR=false
+[[ "${1:-}" == "--repair" ]] && REPAIR=true
+[[ "${1:-}" == "--help" ]] && {
+ cat </dev/null; then
+ GATEWAY_ACTIVE=true
+fi
+
+# 2. Check acpx in recent logs (only if gateway is running)
+if $GATEWAY_ACTIVE; then
+ LOG_LINES=$(journalctl --user -u openclaw-gateway -n 100 --no-pager 2>/dev/null || true)
+
+ if echo "$LOG_LINES" | grep -q "acpx"; then
+ ACPX_SEEN=true
+ fi
+
+ if echo "$LOG_LINES" | grep -qi "error\|crash\|ENOENT\|failed.*acpx\|acpx.*failed"; then
+ ERROR_SEEN=true
+ fi
+fi
+
+# 3. Attempt repair if requested and unhealthy
+if $REPAIR && $GATEWAY_ACTIVE && ! $ACPX_SEEN; then
+ REPAIR_ATTEMPTED=true
+ ACPX_PKG_DIR="$(cd ~ && npm root -g 2>/dev/null)/openclaw/node_modules"
+ if npm install --omit=dev --no-save "acpx@0.1.13" --prefix "$ACPX_PKG_DIR" 2>/dev/null; then
+ REPAIR_SUCCESS=true
+ else
+ REPAIR_SUCCESS=false
+ fi
+fi
+
+# 4. Determine status and recommendation
+STATUS="healthy"
+RECOMMENDATION="ACP is healthy. Use sessions_spawn with runtime=\"acp\" and agentId=\"claude\"."
+
+if ! $GATEWAY_ACTIVE; then
+ STATUS="unhealthy"
+ RECOMMENDATION="Gateway is not running. Run: systemctl --user start openclaw-gateway"
+elif ! $ACPX_SEEN && ! $ERROR_SEEN; then
+ # Gateway running but acpx hasn't appeared yet — might be initializing
+ STATUS="degraded"
+ RECOMMENDATION="acpx plugin not seen in logs. May still be initializing. Try --repair or check: journalctl --user -u openclaw-gateway -n 100 | grep acpx. Fall back to run-delegation.sh if urgent."
+elif $ERROR_SEEN; then
+ STATUS="degraded"
+ RECOMMENDATION="Errors detected in acpx logs. Run with --repair to attempt npm reinstall. Fall back to ~/.openclaw/scripts/run-delegation.sh if time-sensitive."
+fi
+
+jq -n \
+ --arg status "$STATUS" \
+ --argjson gateway_active "$GATEWAY_ACTIVE" \
+ --argjson acpx_seen "$ACPX_SEEN" \
+ --argjson error_seen "$ERROR_SEEN" \
+ --argjson repair_attempted "$REPAIR_ATTEMPTED" \
+ --argjson repair_success "$REPAIR_SUCCESS" \
+ --arg recommendation "$RECOMMENDATION" \
+ --arg fallback "$HOME/.openclaw/scripts/run-delegation.sh" \
+ '{
+ status: $status,
+ gateway_active: $gateway_active,
+ acpx_seen: $acpx_seen,
+ errors_detected: $error_seen,
+ repair_attempted: $repair_attempted,
+ repair_success: $repair_success,
+ recommendation: $recommendation,
+ fallback: $fallback
+ }'
+
+# Exit codes
+if ! $GATEWAY_ACTIVE; then
+ exit 2
+elif [[ "$STATUS" != "healthy" ]]; then
+ exit 1
+else
+ exit 0
+fi
diff --git a/bates-core/scripts-core/agent-ctl.sh b/bates-core/scripts-core/agent-ctl.sh
new file mode 100755
index 0000000..3862710
--- /dev/null
+++ b/bates-core/scripts-core/agent-ctl.sh
@@ -0,0 +1,161 @@
+#!/usr/bin/env bash
+# agent-ctl.sh — start/stop/status for on-demand sub-agent gateways
+# Usage: agent-ctl.sh start [--wait]
+# agent-ctl.sh stop
+# agent-ctl.sh status [agent]
+# agent-ctl.sh wake # start + wait for health
+set -euo pipefail
+
+AGENTS_DIR="$HOME/.openclaw/agents"
+
+# Agent → health port map (gateway port + 3)
+declare -A HEALTH_PORTS=(
+ [amara]=18853 [archer]=19033 [conrad]=18813 [dash]=18893
+ [jules]=18873 [kira]=18953 [mercer]=18933 [mira]=18913
+ [nova]=18973 [paige]=18993 [quinn]=19013 [soren]=18833
+)
+
+# Agents that should always stay running (never auto-stopped)
+# With maxSpawnDepth:2, all deputies delegate via main — none need always-on
+ALWAYS_ON=""
+
+SERVICE_PREFIX="openclaw-agent@"
+
+is_always_on() {
+ local agent="$1"
+ [[ " $ALWAYS_ON " == *" $agent "* ]]
+}
+
+get_health_port() {
+ local agent="$1"
+ echo "${HEALTH_PORTS[$agent]:-}"
+}
+
+is_running() {
+ local agent="$1"
+ systemctl --user is-active "${SERVICE_PREFIX}${agent}.service" &>/dev/null
+}
+
+wait_for_health() {
+ local agent="$1"
+ local port
+ port=$(get_health_port "$agent")
+ if [[ -z "$port" ]]; then
+ echo "WARNING: no health port for $agent, skipping health check" >&2
+ return 0
+ fi
+ local max_wait=30
+ local waited=0
+ while (( waited < max_wait )); do
+ if curl -sf --max-time 2 "http://127.0.0.1:${port}/" &>/dev/null; then
+ return 0
+ fi
+ sleep 1
+ (( waited++ ))
+ done
+ echo "WARNING: $agent health check timed out after ${max_wait}s" >&2
+ return 1
+}
+
+cmd_start() {
+ local agent="$1"
+ local do_wait="${2:-}"
+
+ if [[ -z "${HEALTH_PORTS[$agent]:-}" ]]; then
+ echo "ERROR: unknown agent '$agent'" >&2
+ exit 1
+ fi
+
+ if is_running "$agent"; then
+ echo "$agent: already running"
+ return 0
+ fi
+
+ echo "$agent: starting..."
+ systemctl --user start "${SERVICE_PREFIX}${agent}.service"
+
+ if [[ "$do_wait" == "--wait" ]]; then
+ if wait_for_health "$agent"; then
+ echo "$agent: ready (health OK)"
+ else
+ echo "$agent: started but health check failed" >&2
+ fi
+ else
+ echo "$agent: start signal sent"
+ fi
+}
+
+cmd_wake() {
+ # Alias for start --wait
+ local agent="$1"
+ cmd_start "$agent" "--wait"
+}
+
+cmd_stop() {
+ local agent="$1"
+
+ if [[ -z "${HEALTH_PORTS[$agent]:-}" ]]; then
+ echo "ERROR: unknown agent '$agent'" >&2
+ exit 1
+ fi
+
+ if is_always_on "$agent"; then
+ echo "$agent: marked always-on, refusing to stop (use systemctl directly to override)" >&2
+ return 1
+ fi
+
+ if ! is_running "$agent"; then
+ echo "$agent: already stopped"
+ return 0
+ fi
+
+ echo "$agent: stopping..."
+ systemctl --user stop "${SERVICE_PREFIX}${agent}.service"
+ echo "$agent: stopped"
+}
+
+cmd_status() {
+ local filter="${1:-}"
+ printf "%-10s %-10s %-8s %-10s\n" "AGENT" "STATUS" "PORT" "MODE"
+ printf "%-10s %-10s %-8s %-10s\n" "-----" "------" "----" "----"
+
+ for agent in $(echo "${!HEALTH_PORTS[@]}" | tr ' ' '\n' | sort); do
+ if [[ -n "$filter" && "$agent" != "$filter" ]]; then
+ continue
+ fi
+ local port="${HEALTH_PORTS[$agent]}"
+ local gw_port=$(( port - 3 ))
+ local status="stopped"
+ if is_running "$agent"; then
+ status="running"
+ fi
+ local mode="on-demand"
+ if is_always_on "$agent"; then
+ mode="always-on"
+ fi
+ printf "%-10s %-10s %-8s %-10s\n" "$agent" "$status" "$gw_port" "$mode"
+ done
+}
+
+# Main dispatch
+case "${1:-}" in
+ start)
+ [[ -z "${2:-}" ]] && { echo "Usage: $0 start [--wait]" >&2; exit 1; }
+ cmd_start "$2" "${3:-}"
+ ;;
+ stop)
+ [[ -z "${2:-}" ]] && { echo "Usage: $0 stop " >&2; exit 1; }
+ cmd_stop "$2"
+ ;;
+ wake)
+ [[ -z "${2:-}" ]] && { echo "Usage: $0 wake " >&2; exit 1; }
+ cmd_wake "$2"
+ ;;
+ status)
+ cmd_status "${2:-}"
+ ;;
+ *)
+ echo "Usage: $0 {start|stop|wake|status} [agent] [--wait]" >&2
+ exit 1
+ ;;
+esac
diff --git a/bates-core/scripts-core/agent-idle-watcher.sh b/bates-core/scripts-core/agent-idle-watcher.sh
new file mode 100755
index 0000000..6d11110
--- /dev/null
+++ b/bates-core/scripts-core/agent-idle-watcher.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# agent-idle-watcher.sh — stops on-demand agents that have been idle
+# Runs periodically via cron. Checks session file modification times.
+# An agent is "idle" if no session file has been modified within IDLE_MINUTES.
+set -euo pipefail
+
+IDLE_MINUTES="${AGENT_IDLE_MINUTES:-10}"
+AGENTS_DIR="$HOME/.openclaw/agents"
+AGENT_CTL="$HOME/.openclaw/scripts/agent-ctl.sh"
+LOG_PREFIX="[idle-watcher]"
+
+# On-demand agents only (always-on are protected by agent-ctl)
+ON_DEMAND_AGENTS="amara archer conrad dash jules kira mercer mira nova paige quinn soren"
+
+log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $LOG_PREFIX $*"; }
+
+for agent in $ON_DEMAND_AGENTS; do
+ # Skip if not running
+ if ! systemctl --user is-active "openclaw-agent@${agent}.service" &>/dev/null; then
+ continue
+ fi
+
+ # Check session activity: any .jsonl modified within IDLE_MINUTES?
+ sessions_dir="$AGENTS_DIR/$agent/sessions"
+ state_sessions="$AGENTS_DIR/$agent/state/agents/$agent/sessions"
+
+ active=false
+ for dir in "$sessions_dir" "$state_sessions"; do
+ if [[ -d "$dir" ]]; then
+ recent=$(find "$dir" -maxdepth 1 -name '*.jsonl' -mmin "-${IDLE_MINUTES}" -print -quit 2>/dev/null)
+ if [[ -n "$recent" ]]; then
+ active=true
+ break
+ fi
+ fi
+ done
+
+ if [[ "$active" == "false" ]]; then
+ log "$agent: idle for >${IDLE_MINUTES}m, stopping"
+ "$AGENT_CTL" stop "$agent" 2>&1 | while read -r line; do log "$line"; done
+ fi
+done
diff --git a/bates-core/scripts-core/agent-message.sh b/bates-core/scripts-core/agent-message.sh
new file mode 100755
index 0000000..8a087cf
--- /dev/null
+++ b/bates-core/scripts-core/agent-message.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Send a message from one agent to another via gateway API
+# Usage: agent-message.sh {from} {to} "message text"
+
+TOKENS_FILE="/home/openclaw/.openclaw/shared/config/agent-tokens.json"
+
+declare -A PORTS=(
+ [main]=18789
+ [conrad]=18810 [soren]=18830 [amara]=18850 [jules]=18870
+ [dash]=18890 [mira]=18910 [mercer]=18930 [kira]=18950
+ [nova]=18970 [paige]=18990 [quinn]=19010 [archer]=19030
+)
+
+from="${1:?Usage: $0 from to message}"
+to="${2:?Usage: $0 from to message}"
+message="${3:?Usage: $0 from to message}"
+
+target_port="${PORTS[$to]:-}"
+[[ -z "$target_port" ]] && { echo "Unknown agent: $to"; exit 1; }
+
+# Get auth token for target agent
+if [[ "$to" == "main" ]]; then
+ token=$(jq -r '.gateway.auth.token' /home/openclaw/.openclaw/openclaw.json)
+else
+ token=$(jq -r ".\"$to\"" "$TOKENS_FILE")
+fi
+
+[[ -z "$token" || "$token" == "null" ]] && { echo "No token for $to"; exit 1; }
+
+# Send message via sessions_send endpoint
+payload=$(jq -n \
+ --arg from "$from" \
+ --arg msg "**Message from $from:** $message" \
+ '{
+ sessionKey: ("agent:" + $from + ":inter-agent"),
+ message: $msg
+ }')
+
+response=$(curl -s -X POST \
+ "http://localhost:${target_port}/v1/sessions/send" \
+ -H "Authorization: Bearer ${token}" \
+ -H "Content-Type: application/json" \
+ -d "$payload")
+
+echo "$response"
diff --git a/bates-core/scripts-core/agent-supervisor.sh b/bates-core/scripts-core/agent-supervisor.sh
new file mode 100755
index 0000000..c963f55
--- /dev/null
+++ b/bates-core/scripts-core/agent-supervisor.sh
@@ -0,0 +1,104 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+AGENTS=(conrad soren amara jules dash mira mercer kira nova paige quinn archer)
+declare -A PORTS=(
+ [conrad]=18810 [soren]=18830 [amara]=18850 [jules]=18870
+ [dash]=18890 [mira]=18910 [mercer]=18930 [kira]=18950
+ [nova]=18970 [paige]=18990 [quinn]=19010 [archer]=19030
+)
+
+cmd="${1:-status}"
+target="${2:-}"
+
+start_agent() {
+ local id="$1"
+ echo "Starting $id..."
+ systemctl --user start "openclaw-agent@${id}.service"
+}
+
+stop_agent() {
+ local id="$1"
+ echo "Stopping $id..."
+ systemctl --user stop "openclaw-agent@${id}.service"
+}
+
+restart_agent() {
+ local id="$1"
+ echo "Restarting $id..."
+ systemctl --user restart "openclaw-agent@${id}.service"
+}
+
+health_check() {
+ local port="$1"
+ local result
+ result=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 2 "http://localhost:${port}/health" 2>/dev/null || echo "000")
+ if [[ "$result" == "200" ]]; then
+ echo "healthy"
+ else
+ echo "down"
+ fi
+}
+
+show_status() {
+ printf "%-10s %-6s %-10s %-12s %-8s\n" "AGENT" "PORT" "SYSTEMD" "UPTIME" "HEALTH"
+ printf "%-10s %-6s %-10s %-12s %-8s\n" "-----" "----" "-------" "------" "------"
+ for id in "${AGENTS[@]}"; do
+ port=${PORTS[$id]}
+
+ # Systemd status
+ if systemctl --user is-active "openclaw-agent@${id}.service" &>/dev/null; then
+ svc_status="active"
+ # Get uptime from systemd
+ uptime=$(systemctl --user show "openclaw-agent@${id}.service" --property=ActiveEnterTimestamp --value 2>/dev/null || echo "")
+ if [[ -n "$uptime" && "$uptime" != "n/a" ]]; then
+ uptime_sec=$(( $(date +%s) - $(date -d "$uptime" +%s 2>/dev/null || echo "0") ))
+ if (( uptime_sec > 3600 )); then
+ uptime_str="$((uptime_sec/3600))h$((uptime_sec%3600/60))m"
+ elif (( uptime_sec > 60 )); then
+ uptime_str="$((uptime_sec/60))m"
+ else
+ uptime_str="${uptime_sec}s"
+ fi
+ else
+ uptime_str="-"
+ fi
+ else
+ svc_status="inactive"
+ uptime_str="-"
+ fi
+
+ health=$(health_check "$port")
+ printf "%-10s %-6s %-10s %-12s %-8s\n" "$id" "$port" "$svc_status" "$uptime_str" "$health"
+ done
+}
+
+case "$cmd" in
+ status)
+ show_status
+ ;;
+ start-all)
+ for id in "${AGENTS[@]}"; do start_agent "$id"; done
+ echo "All agents started."
+ ;;
+ stop-all)
+ for id in "${AGENTS[@]}"; do stop_agent "$id"; done
+ echo "All agents stopped."
+ ;;
+ start)
+ [[ -z "$target" ]] && { echo "Usage: $0 start {agent-id}"; exit 1; }
+ start_agent "$target"
+ ;;
+ stop)
+ [[ -z "$target" ]] && { echo "Usage: $0 stop {agent-id}"; exit 1; }
+ stop_agent "$target"
+ ;;
+ restart)
+ [[ -z "$target" ]] && { echo "Usage: $0 restart {agent-id}"; exit 1; }
+ restart_agent "$target"
+ ;;
+ *)
+ echo "Usage: $0 {status|start-all|stop-all|start|stop|restart} [agent-id]"
+ exit 1
+ ;;
+esac
diff --git a/bates-core/scripts-core/archive-sessions.sh b/bates-core/scripts-core/archive-sessions.sh
new file mode 100755
index 0000000..81755f2
--- /dev/null
+++ b/bates-core/scripts-core/archive-sessions.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+# archive-sessions.sh — Move stale .jsonl session files to archive/
+# Runs safely under concurrent execution (mv -n is atomic on same filesystem).
+
+set -euo pipefail
+
+AGENTS_DIR="$HOME/.openclaw/agents"
+MAX_AGE_MIN=120 # 2 hours
+
+total_archived=0
+
+for sessions_dir in "$AGENTS_DIR"/*/sessions/; do
+ [ -d "$sessions_dir" ] || continue
+
+ agent_dir="$(dirname "$sessions_dir")"
+ agent="$(basename "$agent_dir")"
+ archive_dir="$agent_dir/archive"
+
+ count=0
+
+ # Find .jsonl files in the sessions dir (maxdepth 1 to skip subdirs like archive/, state/)
+ # that haven't been modified in the last 120 minutes.
+ while IFS= read -r -d '' file; do
+ mkdir -p "$archive_dir"
+ basename_file="$(basename "$file")"
+ # mv -n: no-clobber, atomic on same filesystem. If two instances race, only one wins.
+ mv -n "$file" "$archive_dir/$basename_file" 2>/dev/null && count=$((count + 1)) || true
+ done < <(find "$sessions_dir" -maxdepth 1 -name '*.jsonl' -type f -mmin +"$MAX_AGE_MIN" -print0 2>/dev/null)
+
+ if [ "$count" -gt 0 ]; then
+ echo "[$(date '+%Y-%m-%d %H:%M:%S')] $agent: archived $count session file(s)"
+ total_archived=$((total_archived + count))
+ fi
+done
+
+if [ "$total_archived" -eq 0 ]; then
+ echo "[$(date '+%Y-%m-%d %H:%M:%S')] No session files older than ${MAX_AGE_MIN}m found."
+else
+ echo "[$(date '+%Y-%m-%d %H:%M:%S')] Total archived: $total_archived file(s)"
+fi
diff --git a/bates-core/scripts-core/bates-update.sh b/bates-core/scripts-core/bates-update.sh
new file mode 100644
index 0000000..c5dca46
--- /dev/null
+++ b/bates-core/scripts-core/bates-update.sh
@@ -0,0 +1,232 @@
+#!/usr/bin/env bash
+# bates-update.sh -- Check for Bates updates and auto-update safe components
+#
+# Checks the getBates/Bates GitHub repo for new releases. If a new version is
+# available, notifies the user via their messaging channel (like any normal
+# Windows app update notification). Does NOT auto-update Bates/OpenClaw — that
+# requires downloading the new installer to preserve patches.
+#
+# Also auto-updates safe standalone tools: Claude Code, Codex CLI, mcporter.
+#
+# Exit codes: 0 = no action needed, 1 = error, 2 = updates applied or available
+
+set -euo pipefail
+
+export PATH="$HOME/.npm-global/bin:$PATH"
+
+BATES_VERSION_FILE="$HOME/.openclaw/bates-version"
+UPDATE_STATE_FILE="$HOME/.openclaw/update-available.json"
+LOG_FILE="${BATES_UPDATE_LOG:-/tmp/bates-update.log}"
+GITHUB_REPO="getBates/Bates"
+
+UPDATED=false
+DRY_RUN=false
+QUIET=false
+
+usage() {
+ echo "Usage: bates-update.sh [OPTIONS]"
+ echo " --dry-run Check only, don't install anything"
+ echo " --quiet Suppress console output (for cron)"
+ echo " --help Show this help"
+}
+
+log() {
+ local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
+ echo "$msg" >> "$LOG_FILE"
+ if [[ "$QUIET" != "true" ]]; then
+ echo "$msg"
+ fi
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --dry-run) DRY_RUN=true; shift ;;
+ --quiet) QUIET=true; shift ;;
+ --help) usage; exit 0 ;;
+ *) echo "Unknown option: $1"; usage; exit 1 ;;
+ esac
+done
+
+log "=== Bates Update Check ==="
+
+# ============================================================
+# 1. Check getBates/Bates GitHub repo for new releases
+# ============================================================
+log "Checking for Bates updates..."
+
+CURRENT_VERSION="unknown"
+if [[ -f "$BATES_VERSION_FILE" ]]; then
+ CURRENT_VERSION=$(cat "$BATES_VERSION_FILE" | tr -d '[:space:]')
+fi
+
+# Query GitHub releases API (unauthenticated, 60 req/hr limit — fine for daily)
+LATEST_RELEASE=$(curl -sf --max-time 10 \
+ "https://api.github.com/repos/$GITHUB_REPO/releases/latest" 2>/dev/null || echo "")
+
+if [[ -n "$LATEST_RELEASE" ]]; then
+ LATEST_VERSION=$(echo "$LATEST_RELEASE" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)
+tag = data.get('tag_name', '')
+# Strip leading 'v' if present
+print(tag.lstrip('v'))
+" 2>/dev/null || echo "")
+
+ RELEASE_URL=$(echo "$LATEST_RELEASE" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)
+print(data.get('html_url', ''))
+" 2>/dev/null || echo "")
+
+ RELEASE_NOTES=$(echo "$LATEST_RELEASE" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)
+body = data.get('body', '')
+# First 500 chars
+print(body[:500])
+" 2>/dev/null || echo "")
+
+ # Find installer download URL (look for .exe asset)
+ DOWNLOAD_URL=$(echo "$LATEST_RELEASE" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)
+for asset in data.get('assets', []):
+ if asset['name'].endswith('.exe'):
+ print(asset['browser_download_url'])
+ break
+" 2>/dev/null || echo "")
+
+ if [[ -n "$LATEST_VERSION" && "$LATEST_VERSION" != "$CURRENT_VERSION" ]]; then
+ log " Bates update available: $CURRENT_VERSION -> $LATEST_VERSION"
+ log " Release: $RELEASE_URL"
+
+ # Write update state file (dashboard and assistant can read this)
+ cat > "$UPDATE_STATE_FILE" << EOJSON
+{
+ "update_available": true,
+ "current_version": "$CURRENT_VERSION",
+ "latest_version": "$LATEST_VERSION",
+ "release_url": "$RELEASE_URL",
+ "download_url": "$DOWNLOAD_URL",
+ "checked_at": "$(date -Iseconds)"
+}
+EOJSON
+
+ # Notify user via gateway (send a message through the assistant)
+ # Only notify once per version — check if we already notified
+ NOTIFIED_FILE="$HOME/.openclaw/update-notified-$LATEST_VERSION"
+ if [[ ! -f "$NOTIFIED_FILE" && "$DRY_RUN" != "true" ]]; then
+ # Use openclaw CLI to send a notification
+ NOTIFY_MSG="A new version of Bates is available: **v$LATEST_VERSION** (you have v$CURRENT_VERSION)."
+ if [[ -n "$DOWNLOAD_URL" ]]; then
+ NOTIFY_MSG="$NOTIFY_MSG Download it here: $DOWNLOAD_URL"
+ else
+ NOTIFY_MSG="$NOTIFY_MSG Check the release: $RELEASE_URL"
+ fi
+
+ if openclaw notify --message "$NOTIFY_MSG" 2>/dev/null; then
+ touch "$NOTIFIED_FILE"
+ log " User notified about update"
+ else
+ # Fallback: try sending via the gateway API
+ curl -sf --max-time 5 -X POST http://localhost:18789/api/notify \
+ -H "Content-Type: application/json" \
+ -d "{\"message\": \"$NOTIFY_MSG\"}" 2>/dev/null || true
+ touch "$NOTIFIED_FILE"
+ log " User notified about update (via API fallback)"
+ fi
+ elif [[ -f "$NOTIFIED_FILE" ]]; then
+ log " Already notified about v$LATEST_VERSION"
+ fi
+ else
+ log " Bates: up to date ($CURRENT_VERSION)"
+ # Clear stale update state
+ rm -f "$UPDATE_STATE_FILE"
+ fi
+else
+ log " WARNING: Could not reach GitHub API"
+fi
+
+# ============================================================
+# 2. Auto-update standalone tools (safe, no patches involved)
+# ============================================================
+update_npm_package() {
+ local name="$1"
+ local cmd="$2"
+
+ if ! command -v "$cmd" &>/dev/null; then
+ log " $name: not installed, skipping"
+ return
+ fi
+
+ local current latest
+ current=$("$cmd" --version 2>/dev/null | grep -oP '[\d]+\.[\d]+\.[\d]+' | head -1 || echo "unknown")
+ latest=$(npm show "$name" version 2>/dev/null || echo "")
+
+ if [[ -z "$latest" ]]; then
+ log " $name: could not check latest version"
+ return
+ fi
+
+ if [[ "$current" == "$latest" ]]; then
+ log " $name: up to date ($current)"
+ else
+ log " $name: $current -> $latest available"
+ if [[ "$DRY_RUN" != "true" ]]; then
+ if npm install -g "$name" 2>/dev/null; then
+ log " $name: updated to $latest"
+ UPDATED=true
+ else
+ log " $name: UPDATE FAILED"
+ fi
+ fi
+ fi
+}
+
+log "Checking tool updates..."
+update_npm_package "@anthropic-ai/claude-code" "claude"
+update_npm_package "@openai/codex" "codex"
+update_npm_package "mcporter" "mcporter"
+
+# ============================================================
+# 3. System packages
+# ============================================================
+log "Checking system packages..."
+if sudo apt-get update -qq 2>/dev/null; then
+ UPGRADABLE=$(apt list --upgradable 2>/dev/null | grep -c upgradable || true)
+ if [[ "$UPGRADABLE" -gt 0 ]]; then
+ log " $UPGRADABLE system packages have updates"
+ if [[ "$DRY_RUN" != "true" ]]; then
+ sudo apt-get upgrade -y -qq 2>/dev/null
+ log " System packages updated"
+ UPDATED=true
+ fi
+ else
+ log " System packages up to date"
+ fi
+else
+ log " WARNING: Could not check system packages"
+fi
+
+# ============================================================
+# 4. Python packages
+# ============================================================
+if [[ -d "$HOME/.openclaw/venv" && "$DRY_RUN" != "true" ]]; then
+ "$HOME/.openclaw/venv/bin/pip" install -q --upgrade requests aiohttp pyyaml 2>/dev/null || true
+ log " Python packages checked"
+fi
+
+# ============================================================
+# 5. Restart gateway if tools were updated
+# ============================================================
+if [[ "$UPDATED" == "true" && "$DRY_RUN" != "true" ]]; then
+ log "Tool updates applied. Restarting gateway..."
+ if systemctl --user restart openclaw-gateway 2>/dev/null; then
+ log "Gateway restarted successfully"
+ else
+ log "WARNING: Gateway restart failed"
+ fi
+fi
+
+log "=== Update check complete ==="
+exit 0
diff --git a/bates-core/scripts-core/build-code-review-card.py b/bates-core/scripts-core/build-code-review-card.py
new file mode 100644
index 0000000..9259bb1
--- /dev/null
+++ b/bates-core/scripts-core/build-code-review-card.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+"""Parse a proposals markdown file and output a read-only Teams Adaptive Card JSON."""
+import json, re, sys
+from datetime import date
+
+def parse_proposals(text):
+ proposals = []
+ for line in text.strip().splitlines():
+ m = re.match(
+ r'\d+\.\s+\*\*(.+?)\*\*\s*\|\s*File:\s*`(.+?)`\s*\|\s*Risk:\s*(\w+)\s*\|\s*(.*)',
+ line.strip()
+ )
+ if m:
+ proposals.append({
+ "title": m.group(1).strip(),
+ "file": m.group(2).strip(),
+ "risk": m.group(3).strip(),
+ "desc": m.group(4).strip(),
+ })
+ return proposals
+
+def build_card(proposals, header_date=None):
+ header_date = header_date or date.today().isoformat()
+ risk_colors = {"High": "attention", "Medium": "warning", "Low": "good"}
+
+ body = [
+ {"type": "TextBlock", "text": f"Code Review Proposals — {header_date}",
+ "size": "Large", "weight": "Bolder", "wrap": True}
+ ]
+
+ for i, p in enumerate(proposals, 1):
+ color = risk_colors.get(p["risk"], "default")
+ body.append({"type": "Container", "separator": True, "items": [
+ {"type": "TextBlock", "text": f"{i}. {p['title']}", "weight": "Bolder", "wrap": True},
+ {"type": "FactSet", "facts": [
+ {"title": "File", "value": p["file"]},
+ {"title": "Risk", "value": p["risk"]},
+ ]},
+ {"type": "TextBlock", "text": p["desc"], "wrap": True, "isSubtle": True,
+ "color": color},
+ ]})
+
+ body.append({"type": "TextBlock", "text": "Reply with proposal numbers to accept (e.g. '1,3' or 'all'). Add instructions after the number (e.g. '1 — use async locks'). Reply 'none' to skip all.",
+ "wrap": True, "separator": True, "spacing": "Medium", "isSubtle": True})
+
+ return {
+ "type": "AdaptiveCard",
+ "version": "1.4",
+ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
+ "body": body,
+ }
+
+if __name__ == "__main__":
+ path = sys.argv[1] if len(sys.argv) > 1 else "/dev/stdin"
+ with open(path) as f:
+ text = f.read()
+ dm = re.search(r'(\d{4}-\d{2}-\d{2})', path)
+ header_date = dm.group(1) if dm else None
+ card = build_card(parse_proposals(text), header_date)
+ json.dump(card, sys.stdout, indent=2)
+ print()
diff --git a/bates-core/scripts-core/check-claude-update.sh b/bates-core/scripts-core/check-claude-update.sh
new file mode 100755
index 0000000..45f40e4
--- /dev/null
+++ b/bates-core/scripts-core/check-claude-update.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# Check if Claude Code has an update available
+# Outputs JSON: {"current":"x.y.z","latest":"x.y.z","update_available":true/false}
+
+CURRENT=$(claude --version 2>/dev/null | grep -oP '[\d]+\.[\d]+\.[\d]+')
+LATEST=$(npm show @anthropic-ai/claude-code version 2>/dev/null)
+
+if [[ -z "$CURRENT" || -z "$LATEST" ]]; then
+ echo '{"error":"Could not determine versions"}'
+ exit 1
+fi
+
+if [[ "$CURRENT" == "$LATEST" ]]; then
+ echo "{\"current\":\"$CURRENT\",\"latest\":\"$LATEST\",\"update_available\":false}"
+else
+ echo "{\"current\":\"$CURRENT\",\"latest\":\"$LATEST\",\"update_available\":true}"
+ exit 2
+fi
diff --git a/bates-core/scripts-core/check-project-mirror.sh b/bates-core/scripts-core/check-project-mirror.sh
new file mode 100755
index 0000000..2279bd7
--- /dev/null
+++ b/bates-core/scripts-core/check-project-mirror.sh
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+# check-project-mirror.sh — Check project mirror freshness before delegation
+#
+# Usage: check-project-mirror.sh [--days N]
+#
+# Projects: fdesk, synapse, escola-caravela, general
+#
+# Exits 0 if fresh, 1 if stale or missing.
+# Outputs JSON to stdout.
+#
+# Examples:
+# check-project-mirror.sh fdesk
+# check-project-mirror.sh synapse --days 7
+
+set -euo pipefail
+
+PROJECTS_DIR="${HOME}/.openclaw/workspace/projects"
+DEFAULT_STALE_DAYS=14
+
+show_help() {
+ cat < [--days N]
+
+Check if a project mirror is fresh enough for delegation use.
+Exits 0 (fresh) or 1 (stale/missing). Outputs JSON.
+
+Projects: fdesk, synapse, escola-caravela, general
+
+Options:
+ --days N Staleness threshold in days (default: $DEFAULT_STALE_DAYS)
+ --help Show this help
+
+JSON output fields:
+ project Project name
+ dir Mirror directory path
+ newest_file Name of most recently modified .md file
+ newest_date Date of newest file (YYYY-MM-DD)
+ age_days Age in days
+ is_stale true if age > threshold
+ warning Human-readable warning message (null if fresh)
+EOF
+}
+
+[[ "${1:-}" == "--help" ]] && { show_help; exit 0; }
+[[ $# -lt 1 ]] && { echo "Error: project name required" >&2; show_help >&2; exit 1; }
+
+PROJECT="$1"
+STALE_DAYS="$DEFAULT_STALE_DAYS"
+
+# Parse optional flags
+shift
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --days) STALE_DAYS="$2"; shift 2 ;;
+ *) echo "Unknown option: $1" >&2; exit 1 ;;
+ esac
+done
+
+# General tasks don't need a project mirror
+if [[ "$PROJECT" == "general" ]]; then
+ echo '{"project":"general","is_stale":false,"warning":null,"message":"No project mirror needed for general tasks."}'
+ exit 0
+fi
+
+PROJECT_DIR="$PROJECTS_DIR/$PROJECT"
+
+if [[ ! -d "$PROJECT_DIR" ]]; then
+ echo "{\"project\":\"$PROJECT\",\"dir\":\"$PROJECT_DIR\",\"is_stale\":true,\"newest_file\":null,\"age_days\":null,\"warning\":\"Project directory not found: $PROJECT_DIR\"}"
+ exit 1
+fi
+
+# Find newest .md file by modification time
+NEWEST_LINE=$(find "$PROJECT_DIR" -type f -name "*.md" -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1)
+
+if [[ -z "$NEWEST_LINE" ]]; then
+ echo "{\"project\":\"$PROJECT\",\"dir\":\"$PROJECT_DIR\",\"is_stale\":true,\"newest_file\":null,\"age_days\":null,\"warning\":\"No .md files found in $PROJECT_DIR\"}"
+ exit 1
+fi
+
+NEWEST_EPOCH=$(echo "$NEWEST_LINE" | awk '{print $1}' | cut -d. -f1)
+NEWEST_FILE=$(echo "$NEWEST_LINE" | awk '{print $2}')
+NEWEST_BASENAME=$(basename "$NEWEST_FILE")
+NEWEST_DATE=$(date -d "@$NEWEST_EPOCH" '+%Y-%m-%d')
+
+NOW_EPOCH=$(date +%s)
+AGE_DAYS=$(( (NOW_EPOCH - NEWEST_EPOCH) / 86400 ))
+
+IS_STALE="false"
+WARNING="null"
+
+if [[ "$AGE_DAYS" -gt "$STALE_DAYS" ]]; then
+ IS_STALE="true"
+ WARNING="\"Mirror is ${AGE_DAYS} days old (threshold: ${STALE_DAYS}d). Run project-sync before delegating.\""
+fi
+
+python3 -c "
+import json, sys
+print(json.dumps({
+ 'project': sys.argv[1],
+ 'dir': sys.argv[2],
+ 'newest_file': sys.argv[3],
+ 'newest_date': sys.argv[4],
+ 'age_days': int(sys.argv[5]),
+ 'is_stale': sys.argv[6] == 'true',
+ 'warning': None if sys.argv[7] == '__null__' else sys.argv[7]
+}))
+" "$PROJECT" "$PROJECT_DIR" "$NEWEST_BASENAME" "$NEWEST_DATE" "$AGE_DAYS" "$IS_STALE" \
+ "$([ "$IS_STALE" = "true" ] && echo "Mirror is ${AGE_DAYS} days old (threshold: ${STALE_DAYS}d). Run project-sync before delegating." || echo "__null__")"
+
+[[ "$IS_STALE" == "true" ]] && exit 1 || exit 0
diff --git a/bates-core/scripts-core/checkin-state.sh b/bates-core/scripts-core/checkin-state.sh
new file mode 100755
index 0000000..cc792b9
--- /dev/null
+++ b/bates-core/scripts-core/checkin-state.sh
@@ -0,0 +1,260 @@
+#!/usr/bin/env bash
+# checkin-state.sh — Manage proactive check-in state (last-checkin.json)
+#
+# Usage:
+# checkin-state.sh read # Print current state as JSON
+# checkin-state.sh check-cooldown # Is this alert in cooldown? (exits 0=ok-to-send, 1=in-cooldown)
+# checkin-state.sh check-suppressed # Is category suppressed? (exits 0=not-suppressed, 1=suppressed)
+# checkin-state.sh update-run [--sent] [--score N] # Update last_run, optionally mark message sent
+# checkin-state.sh add-reported # Add/refresh a reported_items entry
+# checkin-state.sh suppress [--days N] # Suppress a category for N days (default 7)
+# checkin-state.sh prune # Prune old reported_items and expired suppression
+# checkin-state.sh --help
+#
+# State file: ~/.openclaw/workspace/observations/last-checkin.json
+
+set -euo pipefail
+
+STATE_FILE="${HOME}/.openclaw/workspace/observations/last-checkin.json"
+OBS_DIR="${HOME}/.openclaw/workspace/observations"
+
+show_help() {
+ cat < [args]
+
+Manage proactive check-in state file: $STATE_FILE
+
+Commands:
+ read Print current state as JSON
+ check-cooldown Check if alert is in cooldown
+ severity: urgent|text|standard|github
+ Exits 0 = ok to send, 1 = in cooldown
+ check-suppressed Check if category is suppressed
+ Exits 0 = not suppressed, 1 = suppressed
+ update-run [--sent] [--score N] Update last_run timestamp
+ --sent: also update last_message_sent, reset skipped_runs
+ --score N: record score for this run
+ add-reported
+ Add or refresh a reported_items entry
+ suppress [--days N] Suppress category for N days (default: 7)
+ prune Remove entries >7 days old, keep max 50, expire suppressions
+ --help Show this help
+
+Cooldown periods by severity:
+ urgent = 60 minutes
+ text = 12 hours (time-sensitive = 4 hours)
+ standard = 12 hours
+ github = 24 hours
+EOF
+}
+
+[[ "${1:-}" == "--help" ]] && { show_help; exit 0; }
+
+# Ensure state file and directory exist
+mkdir -p "$OBS_DIR"
+if [[ ! -f "$STATE_FILE" ]]; then
+ echo '{"last_run":null,"last_message_sent":null,"skipped_runs":0,"reported_items":[],"suppressed_categories":[]}' > "$STATE_FILE"
+fi
+
+# Validate it's valid JSON
+if ! jq empty "$STATE_FILE" 2>/dev/null; then
+ echo "Error: state file is invalid JSON: $STATE_FILE" >&2
+ exit 1
+fi
+
+CMD="${1:-}"
+shift || true
+
+case "$CMD" in
+
+ read)
+ jq . "$STATE_FILE"
+ ;;
+
+ check-cooldown)
+ ALERT_KEY="${1:-}"
+ SEVERITY="${2:-standard}"
+ if [[ -z "$ALERT_KEY" ]]; then
+ echo '{"error":"alert_key required"}' >&2; exit 1
+ fi
+
+ # Cooldown periods in seconds
+ case "$SEVERITY" in
+ urgent) COOLDOWN=3600 ;; # 60 min
+ text) COOLDOWN=43200 ;; # 12 hours
+ standard) COOLDOWN=43200 ;; # 12 hours
+ github) COOLDOWN=86400 ;; # 24 hours
+ *) COOLDOWN=43200 ;;
+ esac
+
+ NOW=$(date +%s)
+ LAST_SENT=$(jq -r --arg key "$ALERT_KEY" \
+ '.reported_items[] | select(.alert_key == $key) | .last_sent_at // empty' \
+ "$STATE_FILE" | tail -1)
+
+ if [[ -z "$LAST_SENT" ]]; then
+ jq -n --arg key "$ALERT_KEY" --arg sev "$SEVERITY" \
+ '{"alert_key":$key,"severity":$sev,"in_cooldown":false,"reason":"never reported"}'
+ exit 0
+ fi
+
+ LAST_SENT_EPOCH=$(date -d "$LAST_SENT" +%s 2>/dev/null || echo 0)
+ ELAPSED=$(( NOW - LAST_SENT_EPOCH ))
+ REMAINING=$(( COOLDOWN - ELAPSED ))
+
+ if (( ELAPSED < COOLDOWN )); then
+ jq -n \
+ --arg key "$ALERT_KEY" \
+ --arg sev "$SEVERITY" \
+ --argjson remaining "$REMAINING" \
+ --argjson elapsed "$ELAPSED" \
+ --argjson cooldown "$COOLDOWN" \
+ '{"alert_key":$key,"severity":$sev,"in_cooldown":true,"remaining_seconds":$remaining,"elapsed_seconds":$elapsed,"cooldown_seconds":$cooldown}'
+ exit 1
+ else
+ jq -n \
+ --arg key "$ALERT_KEY" \
+ --arg sev "$SEVERITY" \
+ --argjson elapsed "$ELAPSED" \
+ '{"alert_key":$key,"severity":$sev,"in_cooldown":false,"elapsed_seconds":$elapsed}'
+ exit 0
+ fi
+ ;;
+
+ check-suppressed)
+ CATEGORY="${1:-}"
+ if [[ -z "$CATEGORY" ]]; then
+ echo '{"error":"category required"}' >&2; exit 1
+ fi
+
+ NOW=$(date +%s)
+ EXPIRES=$(jq -r --arg cat "$CATEGORY" \
+ '.suppressed_categories[] | select(.category == $cat) | .expires_at // empty' \
+ "$STATE_FILE" | tail -1)
+
+ if [[ -z "$EXPIRES" ]]; then
+ jq -n --arg cat "$CATEGORY" \
+ '{"category":$cat,"suppressed":false,"reason":"not in suppression list"}'
+ exit 0
+ fi
+
+ EXPIRES_EPOCH=$(date -d "$EXPIRES" +%s 2>/dev/null || echo 0)
+ if (( NOW < EXPIRES_EPOCH )); then
+ REMAINING=$(( EXPIRES_EPOCH - NOW ))
+ jq -n --arg cat "$CATEGORY" --arg exp "$EXPIRES" --argjson rem "$REMAINING" \
+ '{"category":$cat,"suppressed":true,"expires_at":$exp,"remaining_seconds":$rem}'
+ exit 1
+ else
+ jq -n --arg cat "$CATEGORY" --arg exp "$EXPIRES" \
+ '{"category":$cat,"suppressed":false,"reason":"suppression expired","expired_at":$exp}'
+ exit 0
+ fi
+ ;;
+
+ update-run)
+ SENT=false
+ SCORE=""
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --sent) SENT=true; shift ;;
+ --score) SCORE="${2:-}"; shift 2 ;;
+ *) shift ;;
+ esac
+ done
+
+ NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+
+ if $SENT; then
+ jq --arg now "$NOW_ISO" \
+ '.last_run = $now | .last_message_sent = $now | .skipped_runs = 0' \
+ "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
+ else
+ jq --arg now "$NOW_ISO" \
+ '.last_run = $now | .skipped_runs = (.skipped_runs + 1)' \
+ "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
+ fi
+
+ jq -n --arg now "$NOW_ISO" --argjson sent "$SENT" \
+ '{"status":"ok","last_run":$now,"message_sent":$sent}'
+ ;;
+
+ add-reported)
+ ALERT_KEY="${1:-}"
+ CATEGORY="${2:-unknown}"
+ STATUS="${3:-reported}"
+ if [[ -z "$ALERT_KEY" ]]; then
+ echo '{"error":"alert_key required"}' >&2; exit 1
+ fi
+
+ NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+
+ # Remove existing entry with same key, then append new one
+ jq --arg key "$ALERT_KEY" --arg cat "$CATEGORY" --arg status "$STATUS" --arg now "$NOW_ISO" \
+ '.reported_items = ([.reported_items[] | select(.alert_key != $key)] + [{"alert_key":$key,"category":$cat,"status":$status,"last_sent_at":$now}])' \
+ "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
+
+ jq -n --arg key "$ALERT_KEY" --arg now "$NOW_ISO" \
+ '{"status":"ok","alert_key":$key,"recorded_at":$now}'
+ ;;
+
+ suppress)
+ CATEGORY="${1:-}"
+ DAYS=7
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --days) DAYS="${2:-7}"; shift 2 ;;
+ *) shift ;;
+ esac
+ done
+ if [[ -z "$CATEGORY" ]]; then
+ echo '{"error":"category required"}' >&2; exit 1
+ fi
+
+ EXPIRES_EPOCH=$(( $(date +%s) + DAYS * 86400 ))
+ EXPIRES_ISO=$(date -d "@${EXPIRES_EPOCH}" -u +"%Y-%m-%dT%H:%M:%SZ")
+
+ # Remove existing suppression for category, add new one
+ jq --arg cat "$CATEGORY" --arg exp "$EXPIRES_ISO" \
+ '.suppressed_categories = ([.suppressed_categories[] | select(.category != $cat)] + [{"category":$cat,"expires_at":$exp}])' \
+ "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
+
+ jq -n --arg cat "$CATEGORY" --arg exp "$EXPIRES_ISO" --argjson days "$DAYS" \
+ '{"status":"ok","category":$cat,"suppressed_until":$exp,"days":$days}'
+ ;;
+
+ prune)
+ CUTOFF_EPOCH=$(( $(date +%s) - 7 * 86400 ))
+ CUTOFF_ISO=$(date -d "@${CUTOFF_EPOCH}" -u +"%Y-%m-%dT%H:%M:%SZ")
+ NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+
+ BEFORE=$(jq '.reported_items | length' "$STATE_FILE")
+
+ jq --arg cutoff "$CUTOFF_ISO" --arg now "$NOW_ISO" '
+ .reported_items = (
+ [.reported_items[] | select(.last_sent_at >= $cutoff)]
+ | .[-50:]
+ ) |
+ .suppressed_categories = [
+ .suppressed_categories[] | select(.expires_at > $now)
+ ]
+ ' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
+
+ AFTER=$(jq '.reported_items | length' "$STATE_FILE")
+ PRUNED=$(( BEFORE - AFTER ))
+
+ jq -n --argjson before "$BEFORE" --argjson after "$AFTER" --argjson pruned "$PRUNED" \
+ '{"status":"ok","items_before":$before,"items_after":$after,"items_pruned":$pruned}'
+ ;;
+
+ "")
+ echo "Error: command required" >&2
+ show_help >&2
+ exit 1
+ ;;
+
+ *)
+ echo "Error: unknown command: $CMD" >&2
+ show_help >&2
+ exit 1
+ ;;
+esac
diff --git a/bates-core/scripts-core/classify-memory.sh b/bates-core/scripts-core/classify-memory.sh
new file mode 100755
index 0000000..9f27cd3
--- /dev/null
+++ b/bates-core/scripts-core/classify-memory.sh
@@ -0,0 +1,103 @@
+#!/usr/bin/env bash
+# classify-memory.sh — Classify and append a memory entry to the correct observations file
+#
+# Usage: classify-memory.sh "" [--source ]
+#
+# Examples:
+# classify-memory.sh goal "Reduce Bates monthly API cost to under \$50" --source "Robert's message"
+# classify-memory.sh contact "Ian Foley - former Binance CBO, Synapse advisor" --source "email"
+# classify-memory.sh pattern "Robert reviews email first, then switches to Cursor" --source "observation"
+
+set -euo pipefail
+
+OBS_DIR="${HOME}/.openclaw/workspace/observations"
+
+show_help() {
+ cat < "" [--source ]
+
+Append a tagged memory entry to the appropriate observations file.
+Handles deduplication (skips if identical content already exists).
+
+Tags → File:
+ goal Something Robert wants to achieve → findings.md
+ fact Reference information (stable) → findings.md
+ preference How Robert wants something done → findings.md
+ deadline A hard date/time commitment → findings.md
+ decision A choice Robert made → findings.md
+ contact Information about a person → findings.md
+ pattern A recurring process or behavior observed → patterns.md
+
+Options:
+ --source Where you learned this (default: "unspecified")
+ --help Show this help
+
+JSON output:
+ { "tag": "...", "file": "...", "status": "ok|skipped", "reason": "..." }
+EOF
+}
+
+[[ "${1:-}" == "--help" ]] && { show_help; exit 0; }
+[[ $# -lt 2 ]] && { echo "Error: tag and content required" >&2; show_help >&2; exit 1; }
+
+TAG="$1"
+CONTENT="$2"
+SOURCE="unspecified"
+
+shift 2
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --source) SOURCE="$2"; shift 2 ;;
+ *) echo "Unknown option: $1" >&2; exit 1 ;;
+ esac
+done
+
+# Route tag to file
+case "$TAG" in
+ goal|fact|preference|deadline|decision|contact)
+ TARGET_FILE="$OBS_DIR/findings.md" ;;
+ pattern)
+ TARGET_FILE="$OBS_DIR/patterns.md" ;;
+ *)
+ echo "Error: Unknown tag '$TAG'" >&2
+ echo "Valid tags: goal, fact, preference, deadline, decision, contact, pattern" >&2
+ exit 1 ;;
+esac
+
+mkdir -p "$OBS_DIR"
+
+TODAY=$(date '+%Y-%m-%d')
+ENTRY="- [$TAG] $CONTENT (source: $SOURCE)"
+
+# Deduplication check — skip if identical tag+content entry already in file
+if [[ -f "$TARGET_FILE" ]] && grep -qF "[$TAG] $CONTENT" "$TARGET_FILE" 2>/dev/null; then
+ echo "⚠️ Duplicate detected — skipping" >&2
+ echo "{\"tag\":\"$TAG\",\"file\":\"$TARGET_FILE\",\"status\":\"skipped\",\"reason\":\"duplicate content already exists\"}"
+ exit 0
+fi
+
+# Insert under today's date section (or prepend new section)
+python3 -c "
+import sys, os
+marker = '## ' + sys.argv[1]
+entry = sys.argv[2]
+path = sys.argv[3]
+if os.path.exists(path):
+ with open(path) as f:
+ lines = f.readlines()
+else:
+ lines = []
+inserted = False
+for i, line in enumerate(lines):
+ if line.strip() == marker:
+ lines.insert(i + 1, entry + '\n')
+ inserted = True
+ break
+if not inserted:
+ lines = [marker + '\n', entry + '\n', '\n'] + lines
+with open(path, 'w') as f:
+ f.writelines(lines)
+" "$TODAY" "$ENTRY" "$TARGET_FILE"
+
+echo "✓ Appended [$TAG] to $(basename "$TARGET_FILE")" >&2
+echo "{\"tag\":\"$TAG\",\"file\":\"$TARGET_FILE\",\"status\":\"ok\",\"today\":\"$TODAY\",\"entry\":$(echo "$ENTRY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))')}"
diff --git a/bates-core/scripts-core/claude-sub.sh b/bates-core/scripts-core/claude-sub.sh
new file mode 100755
index 0000000..a21cceb
--- /dev/null
+++ b/bates-core/scripts-core/claude-sub.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+# Wrapper to call Claude Code using subscription auth only.
+# Strips ANTHROPIC_API_KEY so Claude Code falls back to OAuth credentials.
+env -u ANTHROPIC_API_KEY claude "$@"
diff --git a/bates-core/scripts-core/claude-tmux.sh b/bates-core/scripts-core/claude-tmux.sh
new file mode 100755
index 0000000..96117b8
--- /dev/null
+++ b/bates-core/scripts-core/claude-tmux.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+# claude-tmux.sh — Run Claude Code inside a persistent tmux session.
+#
+# Usage:
+# claude-tmux # attach or create session, auto-resume last conversation
+# claude-tmux new # attach or create session, start fresh conversation
+#
+# If the tmux session "claude" exists:
+# - If Claude Code is still running inside it → just attach
+# - If the shell is idle (Claude exited) → restart Claude with --resume
+# If no session exists → create one and start Claude
+#
+# To detach without killing Claude: press Ctrl+B then D
+# To reattach later: just run `claude-tmux` again
+
+SESSION="claude"
+WORKDIR="/mnt/c/Users/openclaw"
+MODE="${1:-resume}"
+
+# Check if session already exists
+if tmux has-session -t "$SESSION" 2>/dev/null; then
+ # Session exists. Check if Claude Code is running inside it.
+ PANE_PID=$(tmux list-panes -t "$SESSION" -F '#{pane_pid}' 2>/dev/null)
+ CLAUDE_RUNNING=false
+ if [ -n "$PANE_PID" ]; then
+ # Check if any child process of the pane shell is claude
+ if pgrep -P "$PANE_PID" -f "claude" >/dev/null 2>&1; then
+ CLAUDE_RUNNING=true
+ fi
+ fi
+
+ if $CLAUDE_RUNNING; then
+ echo "Claude Code is still running — reattaching..."
+ tmux attach -t "$SESSION"
+ else
+ echo "Session exists but Claude exited — restarting Claude Code..."
+ if [ "$MODE" = "new" ]; then
+ tmux send-keys -t "$SESSION" "cd $WORKDIR && claude" Enter
+ else
+ tmux send-keys -t "$SESSION" "cd $WORKDIR && claude --resume" Enter
+ fi
+ sleep 1
+ tmux attach -t "$SESSION"
+ fi
+else
+ # No session — create one
+ echo "Creating new tmux session '$SESSION'..."
+ if [ "$MODE" = "new" ]; then
+ tmux new-session -d -s "$SESSION" -c "$WORKDIR" "claude"
+ else
+ tmux new-session -d -s "$SESSION" -c "$WORKDIR" "claude --resume"
+ fi
+ sleep 1
+ tmux attach -t "$SESSION"
+fi
diff --git a/bates-core/scripts-core/coding-health-monitor.py b/bates-core/scripts-core/coding-health-monitor.py
new file mode 100644
index 0000000..9d6701d
--- /dev/null
+++ b/bates-core/scripts-core/coding-health-monitor.py
@@ -0,0 +1,187 @@
+#!/usr/bin/env python3
+"""Coding Health Monitor — detects edit loops and thrashing in session transcripts.
+
+Scans JSONL session files for:
+ 1. Same-file thrashing: a file edited 4+ times in one session
+ 2. Edit-error loops: edit → error → edit → error cycles (3+ cycles)
+
+Usage:
+ python3 coding-health-monitor.py # last 24 hours
+ python3 coding-health-monitor.py --all # all sessions
+ python3 coding-health-monitor.py --hours 6 # last 6 hours
+ python3 coding-health-monitor.py --json # JSON output (for cron integration)
+"""
+
+import json
+import os
+import sys
+import glob
+from collections import defaultdict
+from datetime import datetime
+
+SESSIONS_DIRS = [
+ os.path.expanduser("~/.openclaw/agents/main/sessions"),
+]
+# Also scan deputy agent sessions
+AGENTS_BASE = os.path.expanduser("~/.openclaw/agents")
+
+EDIT_TOOLS = {"write", "edit"}
+SAME_FILE_THRESHOLD = 4
+LOOP_THRESHOLD = 3
+
+
+def get_all_session_dirs():
+ """Find all session directories across all agents."""
+ dirs = []
+ if os.path.isdir(AGENTS_BASE):
+ for agent in os.listdir(AGENTS_BASE):
+ sessions = os.path.join(AGENTS_BASE, agent, "sessions")
+ if os.path.isdir(sessions):
+ dirs.append(sessions)
+ archive = os.path.join(sessions, "archive")
+ if os.path.isdir(archive):
+ dirs.append(archive)
+ return dirs
+
+
+def extract_file_path(args):
+ """Get file path from tool call arguments."""
+ if isinstance(args, str):
+ try:
+ args = json.loads(args)
+ except (json.JSONDecodeError, TypeError):
+ return None
+ if not isinstance(args, dict):
+ return None
+ for key in ("file_path", "filePath", "path"):
+ if key in args:
+ return args[key]
+ return None
+
+
+def analyze_session(filepath):
+ """Analyze a single JSONL session file for edit patterns."""
+ file_edits = defaultdict(int)
+ edit_events = [] # [{file, error}]
+
+ entries = []
+ try:
+ with open(filepath, errors="replace") as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ entries.append(json.loads(line))
+ except json.JSONDecodeError:
+ pass
+ except (OSError, IOError):
+ return {"thrashing": {}, "loops": {}}
+
+ # Map tool call IDs to file paths
+ pending_calls = {} # callId -> filepath
+
+ for entry in entries:
+ if entry.get("type") != "message":
+ continue
+ msg = entry.get("message", {})
+ role = msg.get("role", "")
+
+ if role == "assistant":
+ for c in msg.get("content", []):
+ if not isinstance(c, dict):
+ continue
+ tool_name = (c.get("name") or c.get("toolName") or "").lower()
+ if c.get("type") in ("toolCall", "tool_use") and tool_name in EDIT_TOOLS:
+ args = c.get("arguments") or c.get("input") or {}
+ fp = extract_file_path(args)
+ call_id = c.get("id") or c.get("toolCallId") or ""
+ if fp:
+ file_edits[fp] += 1
+ pending_calls[call_id] = fp
+ edit_events.append({"file": fp, "error": None, "call_id": call_id})
+
+ elif role in ("toolResult", "tool_result"):
+ call_id = msg.get("toolCallId") or msg.get("tool_use_id") or ""
+ is_error = msg.get("isError", False) or msg.get("is_error", False)
+ if call_id in pending_calls:
+ # Update the matching event
+ for ev in reversed(edit_events):
+ if ev["call_id"] == call_id and ev["error"] is None:
+ ev["error"] = is_error
+ break
+
+ # Detect same-file thrashing
+ thrashing = {f: c for f, c in file_edits.items() if c >= SAME_FILE_THRESHOLD}
+
+ # Detect edit-error loops per file
+ loops = {}
+ files_seen = set(ev["file"] for ev in edit_events)
+ for fp in files_seen:
+ file_seq = [ev for ev in edit_events if ev["file"] == fp]
+ cycle_count = 0
+ for i in range(len(file_seq) - 1):
+ if file_seq[i].get("error") and file_seq[i + 1].get("error") is not None:
+ cycle_count += 1
+ if cycle_count >= LOOP_THRESHOLD:
+ loops[fp] = cycle_count
+
+ return {"thrashing": thrashing, "loops": loops}
+
+
+def main():
+ hours = 24
+ output_json = "--json" in sys.argv
+ scan_all = "--all" in sys.argv
+
+ for i, arg in enumerate(sys.argv):
+ if arg == "--hours" and i + 1 < len(sys.argv):
+ try:
+ hours = int(sys.argv[i + 1])
+ except ValueError:
+ pass
+
+ cutoff = 0 if scan_all else (datetime.now().timestamp() - hours * 3600)
+
+ session_dirs = get_all_session_dirs()
+ issues = []
+
+ for sdir in session_dirs:
+ for fpath in glob.glob(os.path.join(sdir, "*.jsonl")):
+ try:
+ if os.path.getmtime(fpath) < cutoff:
+ continue
+ except OSError:
+ continue
+
+ result = analyze_session(fpath)
+ if result["thrashing"] or result["loops"]:
+ session_id = os.path.basename(fpath).replace(".jsonl", "")
+ agent = os.path.basename(os.path.dirname(os.path.dirname(fpath)))
+ issues.append({
+ "agent": agent,
+ "session": session_id,
+ "thrashing": result["thrashing"],
+ "loops": result["loops"],
+ })
+
+ if output_json:
+ print(json.dumps({"issues": issues, "scanned_hours": hours if not scan_all else "all"}, indent=2))
+ return
+
+ if not issues:
+ print(f"No edit loop issues detected (scanned last {hours}h).")
+ return
+
+ print(f"Found {len(issues)} session(s) with edit issues:\n")
+ for issue in issues:
+ print(f" Agent: {issue['agent']} Session: {issue['session'][:24]}...")
+ for fp, count in issue["thrashing"].items():
+ print(f" THRASHING: {fp} — edited {count} times")
+ for fp, cycles in issue["loops"].items():
+ print(f" LOOP: {fp} — {cycles} edit-error-edit cycles")
+ print()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/bates-core/scripts-core/collect-standups.sh b/bates-core/scripts-core/collect-standups.sh
new file mode 100755
index 0000000..cb92fc4
--- /dev/null
+++ b/bates-core/scripts-core/collect-standups.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# Collect standup reports from deputy agents into a daily standup file
+set -euo pipefail
+
+AGENTS_DIR="/home/openclaw/.openclaw/agents"
+STANDUPS_DIR="/home/openclaw/.openclaw/shared/standups"
+TODAY=$(date +%Y-%m-%d)
+OUTPUT="$STANDUPS_DIR/$TODAY.md"
+DEPUTIES=(conrad soren amara jules dash mira mercer kira nova paige quinn archer)
+
+mkdir -p "$STANDUPS_DIR"
+
+echo "# Daily Standups — $TODAY" > "$OUTPUT"
+echo "" >> "$OUTPUT"
+
+collected=0
+for agent in "${DEPUTIES[@]}"; do
+ standup="$AGENTS_DIR/$agent/outbox/standup.md"
+ standup_dated="$AGENTS_DIR/$agent/outbox/standup-$TODAY.md"
+ # Prefer date-stamped standup, fall back to plain standup.md
+ if [[ -f "$standup_dated" ]]; then
+ standup="$standup_dated"
+ fi
+ if [[ -f "$standup" ]]; then
+ echo "## $agent" >> "$OUTPUT"
+ echo "" >> "$OUTPUT"
+ cat "$standup" >> "$OUTPUT"
+ echo "" >> "$OUTPUT"
+ rm "$standup"
+ collected=$((collected + 1))
+ else
+ echo "## $agent" >> "$OUTPUT"
+ echo "" >> "$OUTPUT"
+ echo "_No standup submitted._" >> "$OUTPUT"
+ echo "" >> "$OUTPUT"
+ fi
+done
+
+echo "Collected $collected/${#DEPUTIES[@]} standups → $OUTPUT"
+
+# Post each standup to Teams standups channel
+TEAM_ID="640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c"
+CHANNEL_ID="19:c713974d563f428aae7b40ee9f931343@thread.tacv2"
+GRAPH_API="$HOME/.openclaw/scripts/graph-api.sh"
+
+if [[ -x "$GRAPH_API" ]]; then
+ for agent in "${DEPUTIES[@]}"; do
+ content=$(sed -n "/^## $agent$/,/^## /{ /^## $agent$/d; /^## /d; p; }" "$OUTPUT" | sed '/^$/N;/^\n$/d')
+ if [[ -n "$content" && "$content" != *"No standup submitted"* ]]; then
+ html="${agent^}
$(echo "$content" | head -25 | sed 's/&/\&/g; s/\</g; s/>/\>/g')
"
+ payload=$(jq -n --arg body "$html" '{body: {contentType: "html", content: $body}}')
+ "$GRAPH_API" POST "/teams/$TEAM_ID/channels/$CHANNEL_ID/messages" "$payload" >/dev/null 2>&1 || true
+ fi
+ done
+ echo "Posted standups to Teams channel"
+fi
diff --git a/bates-core/scripts-core/compile-briefing.sh b/bates-core/scripts-core/compile-briefing.sh
new file mode 100755
index 0000000..a05ce76
--- /dev/null
+++ b/bates-core/scripts-core/compile-briefing.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Compile morning briefing from today's standups + specialist weekly updates
+set -euo pipefail
+
+AGENTS_DIR="/home/openclaw/.openclaw/agents"
+STANDUPS_DIR="/home/openclaw/.openclaw/shared/standups"
+TODAY=$(date +%Y-%m-%d)
+STANDUP_FILE="$STANDUPS_DIR/$TODAY.md"
+SPECIALISTS=(mercer kira nova paige quinn archer)
+
+echo "═══════════════════════════════════════"
+echo " MORNING BRIEFING — $TODAY"
+echo "═══════════════════════════════════════"
+echo ""
+
+# Deputy standups
+if [[ -f "$STANDUP_FILE" ]]; then
+ echo "── Deputy Standups ──"
+ echo ""
+ cat "$STANDUP_FILE"
+ echo ""
+else
+ echo "── Deputy Standups ──"
+ echo ""
+ echo "No standup file for today. Run collect-standups.sh first."
+ echo ""
+fi
+
+# Specialist weekly updates
+has_updates=false
+for agent in "${SPECIALISTS[@]}"; do
+ update="$AGENTS_DIR/$agent/outbox/weekly-update.md"
+ if [[ -f "$update" ]]; then
+ if [[ "$has_updates" == false ]]; then
+ echo "── Specialist Weekly Updates ──"
+ echo ""
+ has_updates=true
+ fi
+ echo "## $agent"
+ echo ""
+ cat "$update"
+ echo ""
+ fi
+done
+
+if [[ "$has_updates" == false ]]; then
+ echo "── Specialist Weekly Updates ──"
+ echo ""
+ echo "No specialist updates pending."
+fi
+
+echo ""
+echo "═══════════════════════════════════════"
diff --git a/bates-core/scripts-core/cron-channel-router.sh b/bates-core/scripts-core/cron-channel-router.sh
new file mode 100755
index 0000000..b980cff
--- /dev/null
+++ b/bates-core/scripts-core/cron-channel-router.sh
@@ -0,0 +1,129 @@
+#!/usr/bin/env bash
+# cron-channel-router.sh — Map a cron job name to its Teams channel destination
+#
+# Usage: cron-channel-router.sh
+# cron-channel-router.sh --list
+# cron-channel-router.sh --help
+#
+# Outputs JSON: { "cron": "morning-briefing", "channel": "standups", "channel_id": "19:..." }
+#
+# Examples:
+# cron-channel-router.sh morning-briefing
+# cron-channel-router.sh overnight-code-review
+# cron-channel-router.sh --list
+
+set -euo pipefail
+
+show_help() {
+ cat <
+ cron-channel-router.sh --list
+
+Map a cron job name to its Teams channel destination.
+Outputs JSON with channel name and ID.
+
+Options:
+ --list Show all cron → channel mappings as JSON array
+ --help Show this help
+
+JSON output:
+ { "cron": "morning-briefing", "channel": "standups",
+ "channel_id": "19:...", "condition": null }
+
+Condition field:
+ null = always post
+ "if-items" = only post if items found
+ "project-ops" = route to project-specific ops channel (fdesk-ops, synapse-ops, escola-ops)
+
+Exit codes:
+ 0 Mapping found
+ 1 Unknown cron job
+EOF
+}
+
+[[ "${1:-}" == "--help" ]] && { show_help; exit 0; }
+
+# Channel ID map
+declare -A CHANNEL_IDS=(
+ [general]="19:FEedL9wiNMY6nN-rJUomU0H_qHysdpbjawsZjbBSCuk1@thread.tacv2"
+ [standups]="19:c713974d563f428aae7b40ee9f931343@thread.tacv2"
+ [fdesk-ops]="19:35613cb0484c4387bd7f7d3e6059bf33@thread.tacv2"
+ [synapse-ops]="19:d13b55b2de1b4b559e46b3f50da65124@thread.tacv2"
+ [escola-ops]="19:4406a4934a234cd4bc80fad5e31d4669@thread.tacv2"
+ [escalations]="19:07739ffc2001453d91d289ad19d0623b@thread.tacv2"
+ [private]="19:719e9c4defd9450486716839ee8ff382@thread.tacv2"
+ [cross-business]="19:d94dd3492ccd4878bc130006c6b90cb4@thread.tacv2"
+ [bates-rollout]="19:447ce1f9a8f1420a9d60f82449d84d24@thread.tacv2"
+)
+
+# Cron → channel routing table
+# Format: "channel:condition" (condition is empty string for always)
+declare -A ROUTES=(
+ [morning-briefing]="standups:"
+ [daily-review]="standups:"
+ [monday-weekly-briefing]="standups:"
+ [stale-email-chaser]="escalations:if-items"
+ [overnight-code-review]="cross-business:project-ops"
+ [weekly-managers-report]="cross-business:"
+ [project-staleness-check]="cross-business:"
+ [daily-standup]="standups:"
+ [daily-health-check]="standups:"
+ [daily-pattern-observer]="cross-business:"
+ [rules-codifier]="bates-rollout:"
+ [proactive-checkin]="" # no channel — bot chat only
+)
+
+# --list: output all routes
+if [[ "${1:-}" == "--list" ]]; then
+ echo "["
+ first=true
+ for cron in "${!ROUTES[@]}"; do
+ $first || echo ","
+ first=false
+ ROUTE="${ROUTES[$cron]}"
+ CHANNEL="${ROUTE%%:*}"
+ CONDITION="${ROUTE##*:}"
+ [[ -z "$CONDITION" ]] && CONDITION="null" || CONDITION="\"$CONDITION\""
+ [[ -z "$CHANNEL" ]] && CHANNEL_ID="null" || CHANNEL_ID="\"${CHANNEL_IDS[$CHANNEL]:-unknown}\""
+ [[ -z "$CHANNEL" ]] && CHANNEL_JSON="null" || CHANNEL_JSON="\"$CHANNEL\""
+ printf ' {"cron":"%s","channel":%s,"channel_id":%s,"condition":%s}' \
+ "$cron" "$CHANNEL_JSON" "$CHANNEL_ID" "$CONDITION"
+ done
+ echo ""
+ echo "]"
+ exit 0
+fi
+
+if [[ $# -lt 1 ]]; then
+ echo '{"error":"cron job name required"}' >&2
+ show_help >&2
+ exit 1
+fi
+
+CRON_NAME="$1"
+
+if [[ -z "${ROUTES[$CRON_NAME]+_}" ]]; then
+ jq -n --arg cron "$CRON_NAME" \
+ '{"error":"Unknown cron job","cron":$cron,"hint":"Use --list to see all known cron jobs"}'
+ exit 1
+fi
+
+ROUTE="${ROUTES[$CRON_NAME]}"
+CHANNEL="${ROUTE%%:*}"
+CONDITION="${ROUTE##*:}"
+
+if [[ -z "$CHANNEL" ]]; then
+ jq -n --arg cron "$CRON_NAME" \
+ '{"cron":$cron,"channel":null,"channel_id":null,"condition":null,"note":"bot-chat only, no channel post"}'
+ exit 0
+fi
+
+CHANNEL_ID="${CHANNEL_IDS[$CHANNEL]:-unknown}"
+[[ -z "$CONDITION" ]] && CONDITION_JSON="null" || CONDITION_JSON="\"$CONDITION\""
+
+jq -n \
+ --arg cron "$CRON_NAME" \
+ --arg channel "$CHANNEL" \
+ --arg channel_id "$CHANNEL_ID" \
+ --argjson condition "$CONDITION_JSON" \
+ '{"cron":$cron,"channel":$channel,"channel_id":$channel_id,"condition":$condition}'
diff --git a/bates-core/scripts-core/dashboard-register.sh b/bates-core/scripts-core/dashboard-register.sh
new file mode 100755
index 0000000..8c0f18c
--- /dev/null
+++ b/bates-core/scripts-core/dashboard-register.sh
@@ -0,0 +1,74 @@
+#!/bin/bash
+# Lightweight dashboard registration helper for ad-hoc Claude Code runs.
+# Use this to register exec-based or PTY-based runs that bypass run-delegation.sh.
+#
+# Usage:
+# dashboard-register.sh start "task-name" "description" PID
+# dashboard-register.sh complete "task-name" EXIT_CODE ["optional summary"]
+#
+# All dashboard calls are best-effort (won't fail if dashboard is down).
+
+set -uo pipefail
+
+DASHBOARD_URL="http://localhost:18789"
+
+ACTION="${1:?Usage: dashboard-register.sh start|complete TASK_NAME ...}"
+TASK_NAME="${2:?Missing task name}"
+
+case "$ACTION" in
+ start)
+ DESCRIPTION="${3:-}"
+ PID="${4:-$$}"
+ DELEGATION_ID="$(date +%s)-${PID}"
+
+ # Persist the delegation ID so 'complete' can find it
+ ID_FILE="/tmp/.dashboard-reg-$(echo "$TASK_NAME" | tr ' /' '_-')"
+ echo "$DELEGATION_ID" > "$ID_FILE"
+
+ curl -s -X POST "$DASHBOARD_URL/dashboard/api/delegation/start" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n \
+ --arg id "$DELEGATION_ID" \
+ --arg name "$TASK_NAME" \
+ --arg promptPath "" \
+ --arg logPath "" \
+ --arg description "$DESCRIPTION" \
+ --argjson pid "$PID" \
+ '{id: $id, name: $name, promptPath: $promptPath, logPath: $logPath, description: $description, pid: $pid}'
+ )" > /dev/null 2>&1 || true
+
+ echo "Registered: $TASK_NAME (id=$DELEGATION_ID)"
+ ;;
+
+ complete)
+ EXIT_CODE="${3:-0}"
+ SUMMARY="${4:-}"
+
+ # Recover the delegation ID
+ ID_FILE="/tmp/.dashboard-reg-$(echo "$TASK_NAME" | tr ' /' '_-')"
+ if [[ -f "$ID_FILE" ]]; then
+ DELEGATION_ID="$(cat "$ID_FILE")"
+ rm -f "$ID_FILE"
+ else
+ # Fallback: construct a plausible ID (won't match, but dashboard can still log it)
+ DELEGATION_ID="unknown-$(date +%s)"
+ fi
+
+ curl -s -X POST "$DASHBOARD_URL/dashboard/api/delegation/complete" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n \
+ --arg id "$DELEGATION_ID" \
+ --argjson exitCode "$EXIT_CODE" \
+ --arg logTail "$SUMMARY" \
+ '{id: $id, exitCode: $exitCode, logTail: $logTail}'
+ )" > /dev/null 2>&1 || true
+
+ echo "Completed: $TASK_NAME (id=$DELEGATION_ID, exit=$EXIT_CODE)"
+ ;;
+
+ *)
+ echo "Unknown action: $ACTION" >&2
+ echo "Usage: dashboard-register.sh start|complete TASK_NAME ..." >&2
+ exit 1
+ ;;
+esac
diff --git a/bates-core/scripts-core/eu-date-convert.sh b/bates-core/scripts-core/eu-date-convert.sh
new file mode 100755
index 0000000..f007203
--- /dev/null
+++ b/bates-core/scripts-core/eu-date-convert.sh
@@ -0,0 +1,103 @@
+#!/usr/bin/env bash
+# eu-date-convert.sh — Convert European dd/mm/yy(yy) dates to ISO and named formats
+#
+# Usage: eu-date-convert.sh
+# eu-date-convert.sh --help
+#
+# Robert uses European format (dd/mm/yy). Converts before passing to sub-agents.
+#
+# Outputs JSON: { "input": "01/11/25", "iso": "2025-11-01", "named": "November 1, 2025", "error": null }
+#
+# Examples:
+# eu-date-convert.sh 01/11/25 # → 2025-11-01
+# eu-date-convert.sh 31/12/2025 # → 2025-12-31
+# eu-date-convert.sh "15/03/26" # → 2026-03-15
+
+set -euo pipefail
+
+show_help() {
+ cat <
+
+Convert a European date (dd/mm/yy or dd/mm/yyyy) to ISO 8601 and named formats.
+Outputs JSON to stdout.
+
+WARNING: dd/mm/yy — the month is the middle field, NOT the first.
+ 01/11/25 = November 1, 2025 (NOT January 11)
+
+JSON output fields:
+ input Original input string
+ iso ISO 8601 (YYYY-MM-DD)
+ named Full named format (Month D, YYYY)
+ epoch Unix timestamp (noon UTC of that day)
+ error null on success, error message on failure
+
+Exit codes:
+ 0 Success
+ 1 Parse error
+EOF
+}
+
+[[ "${1:-}" == "--help" ]] && { show_help; exit 0; }
+
+if [[ $# -lt 1 ]]; then
+ echo '{"error":"date argument required"}' >&2
+ exit 1
+fi
+
+INPUT="$1"
+
+# Strip quotes if present
+INPUT="${INPUT//\"/}"
+INPUT="${INPUT//\'/}"
+
+# Expect dd/mm/yy or dd/mm/yyyy
+if ! echo "$INPUT" | grep -qE '^[0-9]{1,2}/[0-9]{1,2}/[0-9]{2,4}$'; then
+ jq -n --arg input "$INPUT" '{"input":$input,"iso":null,"named":null,"epoch":null,"error":"Unrecognized date format. Expected dd/mm/yy or dd/mm/yyyy"}'
+ exit 1
+fi
+
+DAY=$(echo "$INPUT" | cut -d/ -f1)
+MONTH=$(echo "$INPUT" | cut -d/ -f2)
+YEAR=$(echo "$INPUT" | cut -d/ -f3)
+
+# Pad day and month
+DAY=$(printf "%02d" "$DAY")
+MONTH=$(printf "%02d" "$MONTH")
+
+# Expand 2-digit year
+if [[ ${#YEAR} -eq 2 ]]; then
+ if (( YEAR <= 50 )); then
+ YEAR="20${YEAR}"
+ else
+ YEAR="19${YEAR}"
+ fi
+fi
+
+# Validate ranges
+if (( 10#$MONTH < 1 || 10#$MONTH > 12 )); then
+ jq -n --arg input "$INPUT" '{"input":$input,"iso":null,"named":null,"epoch":null,"error":"Month out of range (1-12)"}'
+ exit 1
+fi
+if (( 10#$DAY < 1 || 10#$DAY > 31 )); then
+ jq -n --arg input "$INPUT" '{"input":$input,"iso":null,"named":null,"epoch":null,"error":"Day out of range (1-31)"}'
+ exit 1
+fi
+
+ISO="${YEAR}-${MONTH}-${DAY}"
+
+# Validate using date command
+if ! EPOCH=$(date -d "$ISO 12:00 UTC" +%s 2>/dev/null); then
+ jq -n --arg input "$INPUT" --arg iso "$ISO" \
+ '{"input":$input,"iso":$iso,"named":null,"epoch":null,"error":"Invalid calendar date"}'
+ exit 1
+fi
+
+NAMED=$(date -d "$ISO" "+%B %-d, %Y" 2>/dev/null)
+
+jq -n \
+ --arg input "$INPUT" \
+ --arg iso "$ISO" \
+ --arg named "$NAMED" \
+ --argjson epoch "$EPOCH" \
+ '{"input":$input,"iso":$iso,"named":$named,"epoch":$epoch,"error":null}'
diff --git a/bates-core/scripts-core/find-channel-thread.sh b/bates-core/scripts-core/find-channel-thread.sh
new file mode 100755
index 0000000..02e4ce9
--- /dev/null
+++ b/bates-core/scripts-core/find-channel-thread.sh
@@ -0,0 +1,144 @@
+#!/usr/bin/env bash
+# find-channel-thread.sh — Find existing Teams threads before posting (prevent duplicate top-level posts)
+#
+# Usage:
+# find-channel-thread.sh [keyword] # List recent threads, optionally filter by keyword
+# find-channel-thread.sh --list # List all known channel names
+# find-channel-thread.sh --help # Show this help
+#
+# Returns: JSON array of { thread_id, subject, created, author } sorted by recency
+# Non-zero exit if no matching threads found.
+#
+# Supports Thread Discipline rule from rules/subagent-policy.md:
+# NEVER create a new top-level post if an existing thread covers the same topic.
+# Always run this before posting to check if a thread already exists.
+#
+# Channel names: general, standups, fdesk-ops, synapse-ops, escola-ops,
+# escalations, private, cross-business, bates-rollout
+#
+# Examples:
+# find-channel-thread.sh standups "Standup"
+# → [{"thread_id":"1772054536557","subject":"Standup 2026-03-07","created":"2026-03-07T08:01:00Z","author":"Bates"}]
+#
+# find-channel-thread.sh cross-business "Code Review"
+# → [{"thread_id":"1770987654321","subject":"Code Review 2026-03-05","created":"...","author":"Bates"}]
+#
+# find-channel-thread.sh standups 2>/dev/null | jq -r '.[0].thread_id'
+# → 1772054536557
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+GRAPH_API="${SCRIPT_DIR}/graph-api.sh"
+
+# Teams config
+TEAM_ID="640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c"
+
+declare -A CHANNELS=(
+ [general]="19:FEedL9wiNMY6nN-rJUomU0H_qHysdpbjawsZjbBSCuk1@thread.tacv2"
+ [standups]="19:c713974d563f428aae7b40ee9f931343@thread.tacv2"
+ [fdesk-ops]="19:35613cb0484c4387bd7f7d3e6059bf33@thread.tacv2"
+ [synapse-ops]="19:d13b55b2de1b4b559e46b3f50da65124@thread.tacv2"
+ [escola-ops]="19:4406a4934a234cd4bc80fad5e31d4669@thread.tacv2"
+ [escalations]="19:07739ffc2001453d91d289ad19d0623b@thread.tacv2"
+ [private]="19:719e9c4defd9450486716839ee8ff382@thread.tacv2"
+ [cross-business]="19:d94dd3492ccd4878bc130006c6b90cb4@thread.tacv2"
+ [bates-rollout]="19:447ce1f9a8f1420a9d60f82449d84d24@thread.tacv2"
+)
+
+show_help() {
+ grep '^#' "$0" | sed 's/^# //' | sed 's/^#//'
+ exit 0
+}
+
+list_channels() {
+ echo '{"channels":["general","standups","fdesk-ops","synapse-ops","escola-ops","escalations","private","cross-business","bates-rollout"]}'
+ exit 0
+}
+
+if [[ "${1:-}" == "--help" ]]; then show_help; fi
+if [[ "${1:-}" == "--list" ]]; then list_channels; fi
+
+if [[ $# -lt 1 ]]; then
+ echo '{"error":"usage: find-channel-thread.sh [keyword]"}' >&2
+ exit 1
+fi
+
+CHANNEL_NAME="$1"
+KEYWORD="${2:-}"
+
+# Validate channel
+if [[ -z "${CHANNELS[$CHANNEL_NAME]+x}" ]]; then
+ echo "{\"error\":\"unknown channel: $CHANNEL_NAME. Run --list to see valid names\"}" >&2
+ exit 1
+fi
+
+CHANNEL_ID="${CHANNELS[$CHANNEL_NAME]}"
+
+# Fetch recent top-level messages (not replies) — $select not supported by Teams messages API
+RAW=$("$GRAPH_API" GET "/teams/${TEAM_ID}/channels/${CHANNEL_ID}/messages?\$top=25" 2>/dev/null)
+
+if [[ -z "$RAW" ]] || echo "$RAW" | grep -q '"error"'; then
+ echo '{"error":"Failed to fetch channel messages","raw":'"$(echo "$RAW" | jq -c '.' 2>/dev/null || echo 'null')"'}' >&2
+ exit 1
+fi
+
+# Filter top-level posts (replyToId is null) and optionally by keyword
+python3 - "$RAW" "$KEYWORD" "$CHANNEL_NAME" <<'EOF'
+import json, sys, re
+
+raw = sys.argv[1]
+keyword = sys.argv[2].lower()
+channel = sys.argv[3]
+
+try:
+ data = json.loads(raw)
+except json.JSONDecodeError as e:
+ print(json.dumps({"error": f"JSON parse failed: {e}"}))
+ sys.exit(1)
+
+messages = data.get("value", [])
+results = []
+
+for msg in messages:
+ # Skip replies (they have replyToId)
+ if msg.get("replyToId"):
+ continue
+
+ subject = msg.get("subject") or ""
+ body_content = msg.get("body", {}).get("content", "")
+ # Strip HTML tags for keyword matching
+ body_text = re.sub(r'<[^>]+>', ' ', body_content)
+ created = msg.get("createdDateTime", "")
+ from_obj = msg.get("from") or {}
+ user_obj = from_obj.get("user") or {}
+ app_obj = from_obj.get("application") or {}
+ author = user_obj.get("displayName") or app_obj.get("displayName") or "unknown"
+ thread_id = msg.get("id", "")
+
+ # Keyword filter (if provided)
+ if keyword:
+ search_text = f"{subject} {body_text}".lower()
+ if keyword not in search_text:
+ continue
+
+ results.append({
+ "thread_id": thread_id,
+ "subject": subject if subject else body_text[:80].strip(),
+ "created": created,
+ "author": author,
+ "channel": channel
+ })
+
+if not results:
+ if keyword:
+ print(json.dumps({"found": 0, "channel": channel, "keyword": keyword, "threads": []}))
+ else:
+ print(json.dumps({"found": 0, "channel": channel, "threads": []}))
+ sys.exit(0)
+
+output = {"found": len(results), "channel": channel, "threads": results}
+if keyword:
+ output["keyword"] = keyword
+print(json.dumps(output, indent=2))
+EOF
diff --git a/bates-core/scripts-core/generate-agent-configs.sh b/bates-core/scripts-core/generate-agent-configs.sh
new file mode 100755
index 0000000..07fc3e9
--- /dev/null
+++ b/bates-core/scripts-core/generate-agent-configs.sh
@@ -0,0 +1,214 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Phase 2: Generate openclaw.json configs for each deputy agent
+# Reads agents.yaml, uses main openclaw.json as template
+
+AGENTS_YAML="/home/openclaw/.openclaw/shared/config/agents.yaml"
+MAIN_CONFIG="/home/openclaw/.openclaw/openclaw.json"
+TOKENS_FILE="/home/openclaw/.openclaw/shared/config/agent-tokens.json"
+AGENTS_DIR="/home/openclaw/.openclaw/agents"
+
+# Port allocation
+declare -A PORTS=(
+ [conrad]=18801 [soren]=18802 [amara]=18803 [jules]=18804
+ [dash]=18805 [mira]=18806 [mercer]=18807 [kira]=18808
+ [nova]=18809 [paige]=18810 [quinn]=18811 [archer]=18812
+)
+
+# Model name mapping (agents.yaml shorthand → full model ID)
+declare -A MODEL_MAP=(
+ [opus-4.6]="anthropic/claude-opus-4-6"
+ [sonnet-4.6]="anthropic/claude-sonnet-4-6"
+ [sonnet-4.5]="anthropic/claude-sonnet-4-5-20250929"
+ [gemini-flash]="google/gemini-2.5-flash"
+ [haiku-4.5]="anthropic/claude-haiku-4-5-20251001"
+)
+
+# Fallback map — cross-provider fallbacks first to survive provider-wide rate limits
+declare -A FALLBACK_MAP=(
+ [opus-4.6]='["anthropic/claude-sonnet-4-6", "google-gemini-cli/gemini-3-pro-preview", "openai/gpt-4o"]'
+ [sonnet-4.6]='["google-gemini-cli/gemini-3-pro-preview", "openai/gpt-4o", "anthropic/claude-opus-4-6"]'
+ [sonnet-4.5]='["google/gemini-2.5-flash", "openai/gpt-4o"]'
+ [gemini-flash]='["anthropic/claude-haiku-4-5-20251001", "openai/gpt-4o-mini"]'
+ [haiku-4.5]='["google/gemini-2.5-flash", "openai/gpt-4o-mini"]'
+)
+
+# Heartbeat mapping
+declare -A HEARTBEAT_MAP=(
+ [conrad]=30 [soren]=30 [amara]=60 [jules]=30
+ [dash]=60 [mira]=30 [mercer]=120 [kira]=120
+ [nova]=60 [paige]=120 [quinn]=240 [archer]=240
+)
+
+# Model assignment — matches openclaw.json agents.list (updated 2026-02-17)
+declare -A AGENT_MODEL=(
+ [conrad]=opus-4.6 [soren]=sonnet-4.5 [amara]=sonnet-4.5 [jules]=sonnet-4.5
+ [dash]=sonnet-4.5 [mira]=opus-4.6 [mercer]=opus-4.6 [kira]=sonnet-4.5
+ [nova]=gemini-flash [paige]=sonnet-4.5 [quinn]=gemini-flash [archer]=gemini-flash
+)
+
+# Extract sections from main config
+ENV_VARS=$(jq '.env.vars' "$MAIN_CONFIG")
+MODEL_PROVIDERS=$(jq '.models.providers' "$MAIN_CONFIG")
+AUTH_PROFILES=$(jq '.auth.profiles' "$MAIN_CONFIG")
+TOOLS_MEDIA=$(jq '.tools.media' "$MAIN_CONFIG")
+TOOLS_WEB=$(jq '.tools.web' "$MAIN_CONFIG")
+CONTEXT_PRUNING=$(jq '.agents.defaults.contextPruning' "$MAIN_CONFIG")
+COMPACTION=$(jq '.agents.defaults.compaction' "$MAIN_CONFIG")
+MODEL_ALIASES=$(jq '.agents.defaults.models' "$MAIN_CONFIG")
+
+# Generate or load tokens
+if [[ -f "$TOKENS_FILE" ]]; then
+ TOKENS=$(cat "$TOKENS_FILE")
+else
+ TOKENS="{}"
+fi
+
+errors=0
+
+for agent_id in "${!PORTS[@]}"; do
+ port=${PORTS[$agent_id]}
+ workspace="$AGENTS_DIR/$agent_id"
+ model_short=${AGENT_MODEL[$agent_id]}
+ model_full=${MODEL_MAP[$model_short]}
+ fallbacks=${FALLBACK_MAP[$model_short]}
+ heartbeat=${HEARTBEAT_MAP[$agent_id]}
+
+ # Generate token if not exists
+ existing_token=$(echo "$TOKENS" | jq -r ".\"$agent_id\" // empty")
+ if [[ -z "$existing_token" ]]; then
+ token=$(openssl rand -hex 24)
+ TOKENS=$(echo "$TOKENS" | jq --arg id "$agent_id" --arg t "$token" '.[$id] = $t')
+ else
+ token="$existing_token"
+ fi
+
+ # Build config
+ config=$(jq -n \
+ --argjson env_vars "$ENV_VARS" \
+ --argjson model_providers "$MODEL_PROVIDERS" \
+ --argjson auth_profiles "$AUTH_PROFILES" \
+ --argjson tools_media "$TOOLS_MEDIA" \
+ --argjson tools_web "$TOOLS_WEB" \
+ --argjson context_pruning "$CONTEXT_PRUNING" \
+ --argjson compaction "$COMPACTION" \
+ --argjson model_aliases "$MODEL_ALIASES" \
+ --argjson fallbacks "$fallbacks" \
+ --arg model "$model_full" \
+ --arg workspace "$workspace" \
+ --arg token "$token" \
+ --arg heartbeat "${heartbeat}m" \
+ --argjson port "$port" \
+ --arg agent_id "$agent_id" \
+ '{
+ env: { vars: $env_vars },
+ diagnostics: { enabled: true },
+ update: { channel: "stable", checkOnStart: false },
+ auth: { profiles: $auth_profiles },
+ models: { providers: $model_providers },
+ agents: {
+ defaults: {
+ model: {
+ primary: $model,
+ fallbacks: $fallbacks
+ },
+ imageModel: {
+ primary: $model,
+ fallbacks: $fallbacks
+ },
+ models: $model_aliases,
+ workspace: $workspace,
+ contextPruning: $context_pruning,
+ compaction: $compaction,
+ heartbeat: {
+ every: $heartbeat,
+ model: $model
+ },
+ maxConcurrent: 2,
+ subagents: {
+ maxConcurrent: 2,
+ archiveAfterMinutes: 60,
+ model: $model
+ },
+ sandbox: { mode: "off" }
+ },
+ list: [
+ {
+ id: $agent_id,
+ name: $agent_id,
+ model: {
+ primary: $model,
+ fallbacks: $fallbacks
+ }
+ }
+ ]
+ },
+ tools: {
+ deny: ["browser", "canvas"],
+ web: $tools_web,
+ media: $tools_media,
+ agentToAgent: { enabled: true },
+ elevated: { enabled: false }
+ },
+ messages: {
+ tts: { auto: "off" }
+ },
+ commands: {
+ native: "auto",
+ nativeSkills: "auto",
+ restart: false
+ },
+ session: {
+ reset: {
+ mode: "idle",
+ idleMinutes: 30
+ }
+ },
+ gateway: {
+ port: $port,
+ mode: "local",
+ bind: "localhost",
+ controlUi: false,
+ auth: {
+ mode: "token",
+ token: $token
+ },
+ trustedProxies: ["127.0.0.1"],
+ tailscale: { mode: "off" },
+ http: {
+ endpoints: {
+ chatCompletions: { enabled: false }
+ }
+ }
+ },
+ plugins: {
+ allow: ["cost-tracker"],
+ load: {
+ paths: ["/home/openclaw/.openclaw/extensions/cost-tracker"]
+ },
+ entries: {
+ "cost-tracker": { enabled: true }
+ }
+ }
+ }')
+
+ # Write config
+ config_path="$workspace/openclaw.json"
+ echo "$config" > "$config_path"
+
+ # Validate
+ if jq . < "$config_path" > /dev/null 2>&1; then
+ echo "✓ $agent_id → $config_path (port $port, model $model_short)"
+ else
+ echo "✗ $agent_id → INVALID JSON!"
+ ((errors++))
+ fi
+done
+
+# Save tokens
+echo "$TOKENS" | jq . > "$TOKENS_FILE"
+echo ""
+echo "Tokens saved to $TOKENS_FILE"
+echo "Generated configs for ${#PORTS[@]} agents ($errors errors)"
+exit $errors
diff --git a/bates-core/scripts-core/generate-image.py b/bates-core/scripts-core/generate-image.py
new file mode 100755
index 0000000..e46cc93
--- /dev/null
+++ b/bates-core/scripts-core/generate-image.py
@@ -0,0 +1,172 @@
+#!/usr/bin/env python3
+"""Unified image generation — OpenAI and Google Imagen providers.
+
+Usage:
+ python3 generate-image.py --provider openai --prompt "..." [--model gpt-image-1] [--size 1024x1024]
+ python3 generate-image.py --provider google --prompt "..." [--model imagen-3.0-generate-002] [--aspect-ratio 1:1]
+
+Output: JSON to stdout with {"file": "/path/to/image.png", "prompt": "..."}
+"""
+import argparse
+import base64
+import datetime as dt
+import json
+import os
+import re
+import sys
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+
+def slugify(text: str) -> str:
+ text = text.lower().strip()
+ text = re.sub(r"[^a-z0-9]+", "-", text)
+ text = re.sub(r"-{2,}", "-", text).strip("-")
+ return text or "image"
+
+
+def default_out_dir() -> Path:
+ now = dt.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
+ out = Path("/tmp/bates-images") / now
+ out.mkdir(parents=True, exist_ok=True)
+ return out
+
+
+# ── OpenAI Provider ──────────────────────────────────────────────
+
+def generate_openai(api_key: str, prompt: str, model: str, size: str,
+ quality: str, out_dir: Path) -> dict:
+ url = "https://api.openai.com/v1/images/generations"
+ body = {
+ "model": model,
+ "prompt": prompt,
+ "size": size,
+ "n": 1,
+ }
+ if model != "dall-e-2":
+ body["quality"] = quality
+
+ req = urllib.request.Request(
+ url, method="POST",
+ headers={
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ },
+ data=json.dumps(body).encode(),
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=300) as resp:
+ result = json.loads(resp.read())
+ except urllib.error.HTTPError as e:
+ payload = e.read().decode("utf-8", errors="replace")
+ raise RuntimeError(f"OpenAI Images API failed ({e.code}): {payload}") from e
+
+ data = result["data"][0]
+ image_b64 = data.get("b64_json")
+ image_url = data.get("url")
+
+ filename = f"{slugify(prompt)[:60]}.png"
+ filepath = out_dir / filename
+
+ if image_b64:
+ filepath.write_bytes(base64.b64decode(image_b64))
+ elif image_url:
+ urllib.request.urlretrieve(image_url, filepath)
+ else:
+ raise RuntimeError(f"No image in response: {json.dumps(result)[:400]}")
+
+ return {"file": str(filepath), "prompt": prompt, "provider": "openai", "model": model}
+
+
+# ── Google Imagen Provider ───────────────────────────────────────
+
+def generate_google(api_key: str, prompt: str, model: str,
+ aspect_ratio: str, out_dir: Path) -> dict:
+ url = (f"https://generativelanguage.googleapis.com/v1beta/"
+ f"models/{model}:predict")
+ body = {
+ "instances": [{"prompt": prompt}],
+ "parameters": {
+ "sampleCount": 1,
+ "aspectRatio": aspect_ratio,
+ "personGeneration": "allow_adult",
+ },
+ }
+ req = urllib.request.Request(
+ url, method="POST",
+ headers={
+ "x-goog-api-key": api_key,
+ "Content-Type": "application/json",
+ },
+ data=json.dumps(body).encode(),
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=300) as resp:
+ result = json.loads(resp.read())
+ except urllib.error.HTTPError as e:
+ payload = e.read().decode("utf-8", errors="replace")
+ raise RuntimeError(f"Google Imagen API failed ({e.code}): {payload}") from e
+
+ predictions = result.get("predictions", [])
+ if not predictions:
+ raise RuntimeError(f"No predictions in response: {json.dumps(result)[:400]}")
+
+ pred = predictions[0]
+ img_bytes = base64.b64decode(pred["bytesBase64Encoded"])
+ mime = pred.get("mimeType", "image/png")
+ ext = "png" if "png" in mime else "jpeg"
+
+ filename = f"{slugify(prompt)[:60]}.{ext}"
+ filepath = out_dir / filename
+ filepath.write_bytes(img_bytes)
+
+ return {"file": str(filepath), "prompt": prompt, "provider": "google", "model": model}
+
+
+# ── Main ─────────────────────────────────────────────────────────
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="Generate images via OpenAI or Google Imagen.")
+ ap.add_argument("--provider", required=True, choices=["openai", "google"],
+ help="Image provider: openai or google")
+ ap.add_argument("--prompt", required=True, help="Image description")
+ ap.add_argument("--model", default="",
+ help="Model override (default: gpt-image-1 for openai, imagen-4.0-generate-001 for google)")
+ ap.add_argument("--size", default="1024x1024",
+ help="Image size for OpenAI (1024x1024, 1536x1024, 1024x1536)")
+ ap.add_argument("--quality", default="high",
+ help="Image quality for OpenAI (high, standard, medium, low)")
+ ap.add_argument("--aspect-ratio", default="1:1",
+ help="Aspect ratio for Google Imagen (1:1, 4:3, 3:4, 16:9, 9:16)")
+ ap.add_argument("--out-dir", default="",
+ help="Output directory (default: /tmp/bates-images/)")
+ args = ap.parse_args()
+
+ out_dir = Path(args.out_dir) if args.out_dir else default_out_dir()
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ if args.provider == "openai":
+ api_key = os.environ.get("OPENAI_API_KEY", "").strip()
+ if not api_key:
+ print("Missing OPENAI_API_KEY", file=sys.stderr)
+ return 2
+ model = args.model or "gpt-image-1"
+ result = generate_openai(api_key, args.prompt, model, args.size,
+ args.quality, out_dir)
+
+ elif args.provider == "google":
+ api_key = os.environ.get("GOOGLE_GENERATIVE_AI_API_KEY", "").strip()
+ if not api_key:
+ print("Missing GOOGLE_GENERATIVE_AI_API_KEY", file=sys.stderr)
+ return 2
+ model = args.model or "imagen-4.0-generate-001"
+ result = generate_google(api_key, args.prompt, model,
+ args.aspect_ratio, out_dir)
+
+ print(json.dumps(result, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/bates-core/scripts-core/graph-api-safe.sh b/bates-core/scripts-core/graph-api-safe.sh
new file mode 100755
index 0000000..643617c
--- /dev/null
+++ b/bates-core/scripts-core/graph-api-safe.sh
@@ -0,0 +1,57 @@
+#!/bin/bash
+# Graph API helper — routes ALL requests through the M365 safety gateway.
+#
+# Drop-in replacement for graph-api.sh. Same arguments, same output.
+# The safety gateway holds OAuth tokens and enforces whitelists.
+#
+# Usage: graph-api-safe.sh GET|POST|PUT|DELETE [body] [etag]
+# For JSON: graph-api-safe.sh POST /me/sendMail '{"message":{...}}'
+# For file upload: graph-api-safe.sh PUT /me/drive/root:/path:/content @/local/file
+
+set -euo pipefail
+
+METHOD="$1"
+ENDPOINT="${2// /%20}"
+BODY="${3:-}"
+ETAG="${4:-}"
+
+SOCKET="/run/user/$(id -u)/m365-safety.sock"
+GATEWAY_PATH="/graph/v1.0${ENDPOINT}"
+
+# Check gateway is running
+if [ ! -S "$SOCKET" ]; then
+ echo '{"error":"M365 safety gateway is not running. Start it with: systemctl --user start m365-safety-gateway"}' >&2
+ exit 1
+fi
+
+# Build curl args
+CURL_ARGS=(
+ -s
+ --unix-socket "$SOCKET"
+ -X "$METHOD"
+ "http://localhost${GATEWAY_PATH}"
+ -H "Content-Type: application/json"
+)
+
+if [ -n "$ETAG" ]; then
+ CURL_ARGS+=(-H "If-Match: $ETAG")
+fi
+
+if [ -n "$BODY" ]; then
+ if [[ "$BODY" == @* ]]; then
+ # File upload: binary content
+ FILE_PATH="${BODY#@}"
+ CURL_ARGS=(
+ -s
+ --unix-socket "$SOCKET"
+ -X "$METHOD"
+ "http://localhost${GATEWAY_PATH}"
+ -H "Content-Type: application/octet-stream"
+ --data-binary "@$FILE_PATH"
+ )
+ else
+ CURL_ARGS+=(-d "$BODY")
+ fi
+fi
+
+exec curl "${CURL_ARGS[@]}"
diff --git a/bates-core/scripts-core/graph-api.sh b/bates-core/scripts-core/graph-api.sh
new file mode 100755
index 0000000..d8f1aa7
--- /dev/null
+++ b/bates-core/scripts-core/graph-api.sh
@@ -0,0 +1,54 @@
+#!/bin/bash
+# Graph API helper - refreshes token and executes request
+# Usage: graph-api.sh GET|POST|PUT|DELETE [body]
+# For JSON: graph-api.sh POST /planner/plans '{"owner":"...","title":"..."}'
+# For file upload: graph-api.sh PUT /me/drive/root:/path:/content @/local/file
+
+METHOD="$1"
+ENDPOINT="${2// /%20}" # URL-encode spaces in endpoint path
+BODY="$3"
+ETAG="$4" # Optional If-Match header (for Planner updates)
+
+TOKEN_CACHE="$HOME/.openclaw/assistant/node_modules/@softeria/ms-365-mcp-server/.token-cache.json"
+CLIENT_ID="3b2534d6-597a-4d5a-918d-2ea9e4ea8425"
+TENANT_ID="a523f509-d02e-4799-a80f-b0661d9e01af"
+
+# Refresh token
+mcporter call ms365-assistant.get-current-user select='["id"]' > /dev/null 2>&1
+REFRESH=$(jq -r '.RefreshToken | to_entries[0].value.secret' "$TOKEN_CACHE")
+TOKEN=$(curl -s -X POST "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \
+ -d "client_id=$CLIENT_ID" \
+ -d "refresh_token=$REFRESH" \
+ -d "grant_type=refresh_token" \
+ -d "scope=https://graph.microsoft.com/.default" | jq -r '.access_token')
+
+if [ -z "$BODY" ]; then
+ curl -s -X "$METHOD" "https://graph.microsoft.com/v1.0$ENDPOINT" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json"
+elif [[ "$BODY" == @* ]]; then
+ # File upload: body starts with @ — use binary upload
+ FILE_PATH="${BODY#@}"
+ curl -s -X "$METHOD" "https://graph.microsoft.com/v1.0$ENDPOINT" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/octet-stream" \
+ --data-binary "@$FILE_PATH"
+else
+ ETAG_HEADER=""
+ if [ -n "$ETAG" ]; then
+ ETAG_HEADER="-H"
+ ETAG_VAL="If-Match: $ETAG"
+ fi
+ if [ -n "$ETAG" ]; then
+ curl -s -X "$METHOD" "https://graph.microsoft.com/v1.0$ENDPOINT" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -H "If-Match: $ETAG" \
+ -d "$BODY"
+ else
+ curl -s -X "$METHOD" "https://graph.microsoft.com/v1.0$ENDPOINT" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "$BODY"
+ fi
+fi
diff --git a/bates-core/scripts-core/health-check.sh b/bates-core/scripts-core/health-check.sh
new file mode 100755
index 0000000..a4a731f
--- /dev/null
+++ b/bates-core/scripts-core/health-check.sh
@@ -0,0 +1,143 @@
+#!/bin/bash
+# Health check script for OpenClaw/Bates system
+# Outputs structured JSON to stdout (and optionally saves to observations/health.json)
+
+set -euo pipefail
+
+WORKSPACE="/home/openclaw/.openclaw/workspace"
+CRON_FILE="/home/openclaw/.openclaw/cron/jobs.json"
+CHECKIN_FILE="$WORKSPACE/observations/last-checkin.json"
+OUTPUT_FILE="$WORKSPACE/observations/health.json"
+OPENCLAW_CONFIG="${HOME}/.openclaw/openclaw.json"
+TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-$(python3 -c "import json; print(json.load(open('$OPENCLAW_CONFIG')).get('channels',{}).get('telegram',{}).get('botToken',''))" 2>/dev/null || echo "")}"
+
+NOW=$(date -u +"%Y-%m-%dT%H:%M:%S+00:00")
+
+# 1. Check OpenClaw gateway
+if pgrep -x "openclaw-gate" > /dev/null 2>&1 || pgrep -f "openclaw-gateway" > /dev/null 2>&1; then
+ GATEWAY_STATUS="running"
+ # Get uptime in hours
+ GW_PID=$(pgrep -f "openclaw-gateway" | head -1)
+ if [ -n "$GW_PID" ]; then
+ GW_START=$(ps -o lstart= -p "$GW_PID" 2>/dev/null | xargs -I{} date -d "{}" +%s 2>/dev/null || echo "0")
+ NOW_EPOCH=$(date +%s)
+ if [ "$GW_START" != "0" ]; then
+ UPTIME_HOURS=$(( (NOW_EPOCH - GW_START) / 3600 ))
+ else
+ UPTIME_HOURS=-1
+ fi
+ else
+ UPTIME_HOURS=-1
+ fi
+else
+ GATEWAY_STATUS="down"
+ UPTIME_HOURS=0
+fi
+
+# 2. Check Telegram bot
+TELEGRAM_RESULT=$(curl -s --max-time 5 "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || echo '{"ok":false}')
+if echo "$TELEGRAM_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d.get('ok') else 1)" 2>/dev/null; then
+ TELEGRAM_STATUS="connected"
+else
+ TELEGRAM_STATUS="error"
+fi
+
+# 3. Check MCP servers (test if mcporter is available)
+MCP_STATUS="{}"
+if command -v mcporter &> /dev/null; then
+ # Check each known MCP server by trying a lightweight operation
+ for SERVER in ms365-reader ms365-fdesk-reader ms365-support-reader ms365-assistant; do
+ RESULT=$(timeout 10 mcporter call "$SERVER" list-mail-folders '{}' 2>/dev/null && echo "ok" || echo "error")
+ MCP_STATUS=$(echo "$MCP_STATUS" | python3 -c "
+import sys, json
+d = json.load(sys.stdin)
+d['mcp_${SERVER//-/_}'] = '${RESULT}'
+json.dump(d, sys.stdout)
+" 2>/dev/null || echo "$MCP_STATUS")
+ done
+else
+ MCP_STATUS='{"note":"mcporter not in PATH"}'
+fi
+
+# 4. Last cron execution times
+CRON_RUNS="{}"
+if [ -f "$CRON_FILE" ]; then
+ CRON_RUNS=$(python3 -c "
+import json, sys
+with open('$CRON_FILE') as f:
+ data = json.load(f)
+runs = {}
+for job in data.get('jobs', []):
+ name = job.get('name', 'unknown')
+ last_run = job.get('state', {}).get('lastRunAtMs')
+ if last_run:
+ from datetime import datetime, timezone
+ dt = datetime.fromtimestamp(last_run / 1000, tz=timezone.utc)
+ runs[name] = dt.strftime('%Y-%m-%dT%H:%M:%S+00:00')
+ elif name not in runs:
+ runs[name] = None
+json.dump(runs, sys.stdout)
+" 2>/dev/null || echo '{}')
+fi
+
+# 5. Disk usage
+DISK_PERCENT=$(df -h / | awk 'NR==2 {gsub(/%/,""); print $5}' 2>/dev/null || echo "-1")
+
+# 6. Last checkin summary
+CHECKIN_SUMMARY="{}"
+if [ -f "$CHECKIN_FILE" ]; then
+ CHECKIN_SUMMARY=$(python3 -c "
+import json, sys
+with open('$CHECKIN_FILE') as f:
+ data = json.load(f)
+summary = {
+ 'last_run': data.get('last_run'),
+ 'items_reported_today': len(data.get('reported_items', [])),
+ 'skipped_runs': data.get('skipped_runs', 0)
+}
+json.dump(summary, sys.stdout)
+" 2>/dev/null || echo '{}')
+fi
+
+# 7. Build final JSON
+python3 -c "
+import json, sys
+
+services = {
+ 'openclaw_gateway': '$GATEWAY_STATUS',
+ 'telegram_bot': '$TELEGRAM_STATUS'
+}
+
+# Merge MCP status
+try:
+ mcp = json.loads('''$MCP_STATUS''')
+ services.update(mcp)
+except:
+ services['mcp_note'] = 'check failed'
+
+try:
+ cron_runs = json.loads('''$CRON_RUNS''')
+except:
+ cron_runs = {}
+
+try:
+ checkin = json.loads('''$CHECKIN_SUMMARY''')
+except:
+ checkin = {}
+
+result = {
+ 'timestamp': '$NOW',
+ 'uptime_hours': $UPTIME_HOURS,
+ 'services': services,
+ 'last_cron_runs': cron_runs,
+ 'disk_usage_percent': int('$DISK_PERCENT') if '$DISK_PERCENT'.lstrip('-').isdigit() else -1,
+ 'checkin_summary': checkin
+}
+
+output = json.dumps(result, indent=2)
+print(output)
+
+# Also save to file
+with open('$OUTPUT_FILE', 'w') as f:
+ f.write(output + '\n')
+" 2>/dev/null || echo '{"error": "failed to build health report"}'
diff --git a/bates-core/scripts-core/learning-queue.py b/bates-core/scripts-core/learning-queue.py
new file mode 100644
index 0000000..96134d7
--- /dev/null
+++ b/bates-core/scripts-core/learning-queue.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""Manage a queue of links for overnight learning summary processing."""
+
+import argparse
+import json
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+
+QUEUE_FILE = Path(__file__).parent / ".learning-queue.json"
+
+
+def load_queue():
+ if QUEUE_FILE.exists():
+ return json.loads(QUEUE_FILE.read_text())
+ return {"items": []}
+
+
+def save_queue(queue):
+ QUEUE_FILE.write_text(json.dumps(queue, indent=2) + "\n")
+
+
+def add_item(args):
+ queue = load_queue()
+ # Check for duplicate URL
+ for item in queue["items"]:
+ if item["url"] == args.url and item["status"] == "pending":
+ print(f"Already queued: {args.url}")
+ return
+ queue["items"].append({
+ "url": args.url,
+ "type": args.type,
+ "note": args.note,
+ "added": datetime.now(timezone.utc).isoformat(),
+ "status": "pending",
+ "error": None,
+ })
+ save_queue(queue)
+ print(f"Added: {args.url} ({args.type})")
+
+
+def list_items(args):
+ queue = load_queue()
+ if not queue["items"]:
+ print("Queue is empty.")
+ return
+ for i, item in enumerate(queue["items"], 1):
+ status = item["status"].upper()
+ note = f' — {item["note"]}' if item.get("note") else ""
+ err = f' [error: {item["error"]}]' if item.get("error") else ""
+ print(f" {i}. [{status}] ({item['type']}) {item['url']}{note}{err}")
+
+
+def clear_done(args):
+ queue = load_queue()
+ before = len(queue["items"])
+ queue["items"] = [i for i in queue["items"] if i["status"] == "pending"]
+ after = len(queue["items"])
+ save_queue(queue)
+ print(f"Cleared {before - after} processed items. {after} pending remain.")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Learning queue manager")
+ sub = parser.add_subparsers(dest="command")
+
+ add_p = sub.add_parser("add", help="Add a link to the queue")
+ add_p.add_argument("url", help="URL or file path")
+ add_p.add_argument("--type", required=True, choices=["youtube", "article", "pdf"],
+ help="Content type")
+ add_p.add_argument("--note", default=None, help="Optional note")
+
+ sub.add_parser("list", help="List queued items")
+ sub.add_parser("clear-done", help="Remove processed items")
+
+ args = parser.parse_args()
+ if args.command == "add":
+ add_item(args)
+ elif args.command == "list":
+ list_items(args)
+ elif args.command == "clear-done":
+ clear_done(args)
+ else:
+ parser.print_help()
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/bates-core/scripts-core/log-file-access.sh b/bates-core/scripts-core/log-file-access.sh
new file mode 100755
index 0000000..b4322bb
--- /dev/null
+++ b/bates-core/scripts-core/log-file-access.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+# log-file-access.sh — Append a file access entry to observations/file-index.md
+#
+# Usage:
+# log-file-access.sh "" ""
+# log-file-access.sh --output "" "" # for output files
+# log-file-access.sh --help
+#
+# Options:
+# --output Mark as output/created file (uses output format)
+#
+# Output: JSON with logged=true and full path recorded
+#
+# Examples:
+# log-file-access.sh "/home/openclaw/.openclaw/workspace/projects/fdesk/CONTEXT-DUMP.md" \
+# "fDesk product context, metrics, terminology" "read for delegation prompt"
+#
+# log-file-access.sh --output "/tmp/solatio-summary.md" \
+# "Summary of Solatio email thread" "draft — needs Robert review"
+
+set -euo pipefail
+
+INDEX_FILE="${HOME}/.openclaw/workspace/observations/file-index.md"
+
+show_help() {
+ grep '^#' "$0" | sed 's/^# //' | sed 's/^#//'
+ exit 0
+}
+
+if [[ "${1:-}" == "--help" ]]; then show_help; fi
+
+MODE="access"
+if [[ "${1:-}" == "--output" ]]; then
+ MODE="output"
+ shift
+fi
+
+if [[ $# -lt 3 ]]; then
+ echo '{"error":"usage: log-file-access.sh [--output] \"\" \"\""}' >&2
+ exit 1
+fi
+
+FILE_PATH="$1"
+CONTENTS="$2"
+ACTION="$3"
+DATE=$(date +"%Y-%m-%d")
+TIMESTAMP=$(date +"%Y-%m-%d %H:%M %Z")
+
+# Create index file if it doesn't exist
+if [[ ! -f "$INDEX_FILE" ]]; then
+ mkdir -p "$(dirname "$INDEX_FILE")"
+ cat >"$INDEX_FILE" <<'HEADER'
+# File Index
+
+Auto-maintained by `log-file-access.sh`. Tracks files accessed or created during sessions.
+
+| Date | Path | Contents | Action/Status |
+|------|------|----------|---------------|
+HEADER
+fi
+
+# Escape pipes in fields (markdown table safety)
+SAFE_CONTENTS="${CONTENTS//|/\\|}"
+SAFE_ACTION="${ACTION//|/\\|}"
+
+# Truncate long descriptions
+if [[ ${#SAFE_CONTENTS} -gt 80 ]]; then
+ SAFE_CONTENTS="${SAFE_CONTENTS:0:77}..."
+fi
+
+if [[ "$MODE" == "output" ]]; then
+ PREFIX="📄 OUTPUT"
+else
+ PREFIX="📖 READ"
+fi
+
+echo "| ${DATE} | \`${FILE_PATH}\` | ${SAFE_CONTENTS} | ${PREFIX}: ${SAFE_ACTION} |" >> "$INDEX_FILE"
+
+python3 -c "
+import json
+print(json.dumps({
+ 'logged': True,
+ 'mode': '$MODE',
+ 'path': '$FILE_PATH',
+ 'date': '$DATE',
+ 'index_file': '$INDEX_FILE'
+}))
+"
diff --git a/bates-core/scripts-core/log-overnight-turn.sh b/bates-core/scripts-core/log-overnight-turn.sh
new file mode 100755
index 0000000..dc75e0f
--- /dev/null
+++ b/bates-core/scripts-core/log-overnight-turn.sh
@@ -0,0 +1,163 @@
+#!/usr/bin/env bash
+# log-overnight-turn.sh — Log overnight work turn usage to workspace/reports/overnight-log.md
+#
+# Usage:
+# log-overnight-turn.sh --task "" --turns [--cost-note ""] [--status done|partial|failed]
+# log-overnight-turn.sh --summary # Print current overnight log
+# log-overnight-turn.sh --help # Show this help
+#
+# Output: JSON with logged=true, turns_logged, total_turns_today
+#
+# Tracks:
+# - Per-task turn counts for the current overnight run
+# - Running total (limit: 5 turns per policy from rules/proactive-philosophy.md)
+# - Writes dated entries to workspace/reports/overnight-log.md
+#
+# From rules/proactive-philosophy.md:
+# "Limit Bates orchestration to max 5 API turns per overnight run."
+# "Log turn count and estimated cost in workspace/reports/overnight-log.md"
+#
+# Examples:
+# log-overnight-turn.sh --task "Read transcripts" --turns 1 --status done
+# → {"logged":true,"task":"Read transcripts","turns":1,"total_today":1,"limit":5,"remaining":4}
+#
+# log-overnight-turn.sh --task "Code review batch" --turns 2 --cost-note "3 repos analyzed" --status done
+# → {"logged":true,"task":"Code review batch","turns":2,"total_today":3,"limit":5,"remaining":2}
+#
+# log-overnight-turn.sh --summary
+# → {"date":"2026-03-08","total_turns":3,"limit":5,"remaining":2,"tasks":[...]}
+
+set -euo pipefail
+
+LOG_FILE="${HOME}/.openclaw/workspace/reports/overnight-log.md"
+STATE_FILE="${HOME}/.openclaw/workspace/reports/overnight-state.json"
+TURN_LIMIT=5
+
+show_help() {
+ grep '^#' "$0" | sed 's/^# //' | sed 's/^#//'
+ exit 0
+}
+
+if [[ "${1:-}" == "--help" ]]; then show_help; fi
+
+TODAY=$(date +"%Y-%m-%d")
+NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+
+# Initialize log file if missing
+if [[ ! -f "$LOG_FILE" ]]; then
+ mkdir -p "$(dirname "$LOG_FILE")"
+ cat >"$LOG_FILE" <<'HEADER'
+# Overnight Work Log
+
+Auto-maintained by `log-overnight-turn.sh`. Tracks per-run API turn usage.
+Policy: max 5 Bates orchestration turns per overnight run (rules/proactive-philosophy.md).
+
+HEADER
+fi
+
+# Initialize or load state for today
+load_state() {
+ if [[ -f "$STATE_FILE" ]]; then
+ local state_date
+ state_date=$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(d.get('date',''))" 2>/dev/null || echo "")
+ if [[ "$state_date" == "$TODAY" ]]; then
+ return 0 # State is for today
+ fi
+ fi
+ # Create fresh state for today
+ echo "{\"date\":\"$TODAY\",\"total_turns\":0,\"tasks\":[]}" > "$STATE_FILE"
+}
+
+if [[ "${1:-}" == "--summary" ]]; then
+ load_state
+ python3 - "$STATE_FILE" "$TURN_LIMIT" <<'EOF'
+import json, sys
+with open(sys.argv[1]) as f:
+ state = json.load(f)
+limit = int(sys.argv[2])
+total = state.get("total_turns", 0)
+print(json.dumps({
+ "date": state.get("date"),
+ "total_turns": total,
+ "limit": limit,
+ "remaining": max(0, limit - total),
+ "over_limit": total > limit,
+ "tasks": state.get("tasks", [])
+}, indent=2))
+EOF
+ exit 0
+fi
+
+# Parse args
+TASK=""
+TURNS=1
+COST_NOTE=""
+STATUS="done"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --task) TASK="$2"; shift 2 ;;
+ --turns) TURNS="$2"; shift 2 ;;
+ --cost-note) COST_NOTE="$2"; shift 2 ;;
+ --status) STATUS="$2"; shift 2 ;;
+ *) echo "{\"error\":\"unknown argument: $1\"}" >&2; exit 1 ;;
+ esac
+done
+
+if [[ -z "$TASK" ]]; then
+ echo '{"error":"--task is required"}' >&2
+ exit 1
+fi
+
+load_state
+
+# Update state and write log entry
+python3 - "$STATE_FILE" "$LOG_FILE" "$TASK" "$TURNS" "$COST_NOTE" "$STATUS" "$TODAY" "$NOW" "$TURN_LIMIT" <<'EOF'
+import json, sys
+
+state_file, log_file = sys.argv[1], sys.argv[2]
+task, turns_str, cost_note, status = sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6]
+today, now, limit_str = sys.argv[7], sys.argv[8], sys.argv[9]
+turns = int(turns_str)
+limit = int(limit_str)
+
+with open(state_file) as f:
+ state = json.load(f)
+
+state["total_turns"] = state.get("total_turns", 0) + turns
+state["tasks"].append({"task": task, "turns": turns, "status": status, "cost_note": cost_note, "logged_at": now})
+total = state["total_turns"]
+
+with open(state_file, "w") as f:
+ json.dump(state, f, indent=2)
+
+# Append to log file
+status_icon = {"done": "✅", "partial": "⚠️", "failed": "❌"}.get(status, "•")
+cost_str = f" — {cost_note}" if cost_note else ""
+over = " ⚠️ OVER LIMIT" if total > limit else ""
+
+# Check if today's section header exists in log
+with open(log_file) as f:
+ log_content = f.read()
+
+header = f"## {today}"
+if header not in log_content:
+ with open(log_file, "a") as f:
+ f.write(f"\n## {today}\n\n| Time | Task | Turns | Status | Notes |\n|------|------|-------|--------|-------|\n")
+
+with open(log_file, "a") as f:
+ time_str = now[11:16] + "Z"
+ f.write(f"| {time_str} | {task} | {turns} | {status_icon} {status} | {cost_note} |\n")
+
+print(json.dumps({
+ "logged": True,
+ "task": task,
+ "turns": turns,
+ "status": status,
+ "total_today": total,
+ "limit": limit,
+ "remaining": max(0, limit - total),
+ "over_limit": total > limit,
+ "warning": f"⚠️ {total}/{limit} turns used — consider stopping" if total >= limit else None
+}))
+EOF
diff --git a/bates-core/scripts-core/log-spawn.sh b/bates-core/scripts-core/log-spawn.sh
new file mode 100755
index 0000000..b1c9e93
--- /dev/null
+++ b/bates-core/scripts-core/log-spawn.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+# log-spawn.sh — Log a sub-agent spawn to workspace/reports/subagent-log.md
+#
+# Usage:
+# log-spawn.sh