diff --git a/.rules.md b/.rules.md
index 402d93b..9a9705a 100644
--- a/.rules.md
+++ b/.rules.md
@@ -50,3 +50,17 @@
- Write task artifacts atomically to avoid partial reads by the dashboard or other agents
- Prefer writing to a temporary file and renaming it into place
- Avoid leaving empty or malformed artifacts in the task folder
+
+## Self-Documenting UI Names
+- All user-facing labels must be self-documenting and free of internal jargon
+- A user should understand what something is without knowing the codebase internals
+- Terms like "tick", "loop", "state", or "phase transition" are internal terminology — never use them directly in UI elements
+- Pick names that describe what something *does*, not what it's called in code
+- Past failure (1): "Loop Tick" in the Background tab — user asked "What is loop tick?" because it's scheduler jargon
+- Past failure (2): "Loop" — still too vague, user said "I don't know what the loop is"
+- Past failure (3): "Loop Agent" — user asked "How do I differentiate between agents with different roles?" because the name described the mechanism (loop) not the purpose
+- Past failure (4): "Background" tab — the tab name described the *location* (background), not the *content* (agents). Renamed to "Agent" tab.
+- Past failure (5): Group headers + agent type pill badge + icon + name all repeated the same info ("🧹 Cleanup" seen 3 times per card). Removed group headers and pill badges — only icon + name on each card.
+- Past failure (6): "Cleanup" agent was vague — renamed to "Completed Task Archiver" which describes what it actually does.
+- Rule: Every agent type must have a self-documenting name ending with its function (e.g., "Archiver", "Scanner", "Processor"). If multiple instances exist, list each instance card.
+- Agent types with no active instances render as dimmed placeholder cards rather than being hidden, so the user always sees the full roster of available agents.
diff --git a/automaton/dashboard/config.py b/automaton/dashboard/config.py
index b3a3633..2decc78 100644
--- a/automaton/dashboard/config.py
+++ b/automaton/dashboard/config.py
@@ -13,7 +13,7 @@ DEFAULTS = {
"theme": "default",
}
-VALID_VIEWS = {"board", "statistics", "timeline"}
+VALID_VIEWS = {"board", "statistics", "timeline", "agent"}
VALID_THEMES = {"default", "dark", "light"}
diff --git a/automaton/dashboard/core/task.py b/automaton/dashboard/core/task.py
index 7982849..323e76e 100644
--- a/automaton/dashboard/core/task.py
+++ b/automaton/dashboard/core/task.py
@@ -648,6 +648,8 @@ def discover_tasks(tasks_dir: Path) -> list[Task]:
continue
if folder_path.name == "subtasks":
continue
+ if folder_path.name == "complete":
+ continue
if not _VALID_TASK_NAME_CHARS.issuperset(set(folder_path.name)):
continue
diff --git a/automaton/dashboard/html/dashboard.js b/automaton/dashboard/html/dashboard.js
index 513eca5..9cd7fb8 100644
--- a/automaton/dashboard/html/dashboard.js
+++ b/automaton/dashboard/html/dashboard.js
@@ -327,51 +327,196 @@ function renderStats() {
${waveStats.length > 0 ? `
` : ''}`;
}
-function renderTimeline() {
- const panel = document.getElementById('timeline-panel');
- const filtered = getFilteredTasks();
+const AGENT_TYPE_META = {
+ completed_task_archiver: { icon: '🧹', label: 'Completed Task Archiver', color: '#9aa0a6', desc: 'Archives completed tasks older than a threshold' },
+ single: { icon: '🎯', label: 'Dedicated Agent', color: '#42a5f5', desc: 'Works on a single assigned task' },
+ audit: { icon: '🔍', label: 'Audit Scanner', color: '#ffa726', desc: 'Scans for violations and creates remediation tasks' },
+ backlog: { icon: '📋', label: 'Backlog Processor', color: '#66bb6a', desc: 'Processes items from a backlog queue' },
+};
- // Group phases by phase group
- const phaseGroupStates = {};
- PHASE_GROUPS.forEach(group => {
- phaseGroupStates[group.id] = {
- states: group.states,
- label: group.label,
- color: group.color,
- };
+const AGENT_TYPES = Object.keys(AGENT_TYPE_META);
+const AGENT_TYPE_META_FALLBACK = { icon: '🤖', label: 'Agent', color: '#8ab4f8', desc: '' };
+
+async function fetchSchedule() {
+ try {
+ const res = await fetch('/api/scheduled');
+ const data = await res.json();
+ return data;
+ } catch (err) {
+ console.error('Failed to fetch scheduled jobs:', err);
+ return { jobs: [], available: false };
+ }
+}
+
+function _resolveAgentType(job) {
+ if (job.kind === 'cleanup' || !job.agent_type) return 'completed_task_archiver';
+ return job.agent_type;
+}
+
+function _scheduledStatusBadge(status) {
+ const map = {
+ enabled: { text: 'Enabled', cls: 'ok' },
+ disabled: { text: 'Disabled', cls: 'muted' },
+ misconfigured: { text: 'Misconfigured', cls: 'bad' },
+ };
+ const m = map[status] || { text: status || 'Unknown', cls: 'muted' };
+ return `${m.text} `;
+}
+
+function _renderRuntimeState(rt) {
+ if (!rt) return '';
+ const rows = [];
+ if (rt.status) rows.push(['status', rt.status]);
+ if (rt.iteration_count !== undefined && rt.iteration_count !== null)
+ rows.push(['iterations', rt.iteration_count]);
+ if (rt.halt_reason) rows.push(['halt_reason', rt.halt_reason]);
+ if (rt.current_task) rows.push(['current_task', rt.current_task]);
+ if (Array.isArray(rt.score_history) && rt.score_history.length)
+ rows.push(['scores', rt.score_history.join(', ')]);
+ if (rt.resumed_count) rows.push(['resumed_count', rt.resumed_count]);
+ if (!rows.length) return '';
+ return `Runtime State ${rows.map(r =>
+ `${r[0]} ${r[1]} `).join('')} `;
+}
+
+function _agentRuntimeIndicator(rt) {
+ if (!rt || !rt.status) return '';
+ const map = {
+ running: { icon: '▶', cls: 'ok' },
+ halted: { icon: '⏹', cls: 'bad' },
+ paused: { icon: '⏸', cls: 'muted' },
+ complete: { icon: '✓', cls: 'ok' },
+ };
+ const m = map[rt.status] || { icon: '○', cls: 'muted' };
+ return `${m.icon} ${rt.status} `;
+}
+
+function _agentJobKey(job) {
+ return (job.kind || 'unknown') + ':' + (job.name || 'default');
+}
+
+function _renderAgentCard(meta, job) {
+ const key = _agentJobKey(job);
+ const name = job.name || meta.label;
+ const interval = job.next_run_seconds
+ ? `Every ${Math.round(job.next_run_seconds / 3600)}h` : '—';
+ const stub = job.stub_path
+ ? `
stub ${job.stub_path} `
+ : '';
+ const runtimeHtml = _renderRuntimeState(job.runtime_state);
+ const agentRuntime = _agentRuntimeIndicator(job.runtime_state);
+ const statusBorderCls = job.status === 'enabled' ? 'ok' : job.status === 'misconfigured' ? 'bad' : 'muted';
+ return `
+
+
+
schedule ${interval}
+ ${stub}
+
+
${runtimeHtml}
+
`;
+}
+
+function _renderInactiveCard(meta) {
+ return `
+
+ ${meta.desc ? `
` : ''}
+
`;
+}
+
+async function _agentPollerTick() {
+ const data = await fetchSchedule();
+ if (!data || !data.jobs) return;
+ for (const job of data.jobs) {
+ const key = _agentJobKey(job);
+ const card = document.querySelector(`[data-agent-card="${key}"]`);
+ if (!card) continue;
+ // Update status badge text + class
+ const badge = card.querySelector('[data-agent-field="status-badge"] .bg-status');
+ if (badge) {
+ const map = { enabled: 'Enabled', disabled: 'Disabled', misconfigured: 'Misconfigured' };
+ badge.textContent = map[job.status] || job.status || 'Unknown';
+ badge.className = `bg-status bg-status-${
+ job.status === 'enabled' ? 'ok' : job.status === 'misconfigured' ? 'bad' : 'muted'
+ }`;
+ }
+ // Update runtime indicator
+ const rtIndicator = card.querySelector('[data-agent-field="rt-indicator"]');
+ if (rtIndicator) {
+ rtIndicator.innerHTML = _agentRuntimeIndicator(job.runtime_state);
+ }
+ // Update runtime state section
+ const rtSection = card.querySelector('[data-agent-field="runtime-state"]');
+ if (rtSection) {
+ rtSection.innerHTML = _renderRuntimeState(job.runtime_state);
+ }
+ // Update card border
+ const borderCls = job.status === 'enabled' ? 'ok' : job.status === 'misconfigured' ? 'bad' : 'muted';
+ card.className = card.className.replace(/bg-status-\w+/g, `bg-status-${borderCls}`);
+ }
+}
+
+let _agentPoller = null;
+const AGENT_POLL_INTERVAL = 5000;
+
+function _stopAgentPoller() {
+ if (_agentPoller) {
+ clearInterval(_agentPoller);
+ _agentPoller = null;
+ }
+}
+
+function _startAgentPoller() {
+ _stopAgentPoller();
+ _agentPoller = setInterval(_agentPollerTick, AGENT_POLL_INTERVAL);
+}
+
+async function renderAgentTab() {
+ const panel = document.getElementById('agent-panel');
+ if (!panel) return;
+ // If poller is already running, do a targeted update instead of full rebuild
+ if (_agentPoller) {
+ await _agentPollerTick();
+ return;
+ }
+ panel.innerHTML = 'Loading agents…
';
+ const data = await fetchSchedule();
+ const jobs = data.jobs || [];
+ if (data.available === false) {
+ panel.innerHTML = 'No agents available (not running from ~/.automaton).
';
+ return;
+ }
+ if (data.error) {
+ panel.innerHTML = `Error: ${data.error}
`;
+ return;
+ }
+ // Group jobs by agent type
+ const groups = {};
+ jobs.forEach(j => {
+ const key = _resolveAgentType(j);
+ (groups[key] = groups[key] || []).push(j);
});
-
- // Legend: show phase groups
- const legend = PHASE_GROUPS.map(group => {
- return `${group.label}
`;
+ // Render a card for each agent type — active instances or placeholder
+ const cardsHtml = AGENT_TYPES.map(key => {
+ const meta = AGENT_TYPE_META[key];
+ const typeJobs = groups[key];
+ if (!typeJobs || typeJobs.length === 0) {
+ return _renderInactiveCard(meta);
+ }
+ return typeJobs.map(job => _renderAgentCard(meta, job)).join('');
}).join('');
-
- const itemsHtml = filtered.map(task => {
- // For each phase group, check if the task has completed any state in that group
- const phaseGroupHtml = PHASE_GROUPS.map(group => {
- const groupStates = group.states;
- const hasArtifact = groupStates.some(s => task.artifacts[s]);
- const isComplete = task.state === 'done' || hasArtifact;
- const isCurrent = groupStates.includes(task.state) && !hasArtifact;
- const cls = isComplete ? 'complete' : isCurrent ? 'in_progress' : 'incomplete';
- const icon = isComplete ? '✓' : isCurrent ? '◐' : '○';
- return `${icon}
`;
- }).join('');
- const statusClass = task.state === 'done' ? 'done' : task.state === 'blocked' ? 'blocked' : 'in_progress';
- const statusIcon = task.state === 'done' ? '✅' : task.state === 'blocked' ? '❌' : '🔄';
- const waveHtml = task.sub_tasks.length > 0
- ? `Sub-tasks (${task.sub_tasks.filter(st => st.has_verdict).length}/${task.sub_tasks.length})
-
${task.sub_tasks.map(st => {
- const stStatus = st.has_verdict && st.verdict_status === 'PASS' ? 'pass' : st.has_verdict && st.verdict_status === 'FAIL' ? 'fail' : 'incomplete';
- const stIcon = stStatus === 'pass' ? '✓' : stStatus === 'fail' ? '✗' : '○';
- return `
${stIcon} ${st.name}
`;
- }).join('')}
`
- : '';
- return `
-
-
${phaseGroupHtml}
${waveHtml}
`;
- }).join('');
- panel.innerHTML = `${itemsHtml}`;
+ panel.innerHTML = `${cardsHtml}
`;
+ _startAgentPoller();
}
async function submitReview(taskName, status) {
@@ -414,14 +559,19 @@ function switchView(view) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
const target = document.getElementById(`view-${view}`);
if (target) target.classList.add('active');
- renderCurrentView();
+ if (view === 'agent') {
+ renderAgentTab(); // full render on switch; starts poller
+ } else {
+ _stopAgentPoller();
+ renderCurrentView();
+ }
}
function renderCurrentView() {
switch (state.currentView) {
case 'board': renderBoard(); break;
case 'stats': renderStats(); break;
- case 'timeline': renderTimeline(); break;
+ // Agent tab runs its own independent poller — not refreshed on global ticks
}
renderHeader();
}
@@ -451,10 +601,10 @@ function attachCardListeners() {
function setupKeyboard() {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { closeDetail(); document.getElementById('help-modal').classList.remove('open'); document.getElementById('search-input').value = ''; state.searchQuery = ''; renderCurrentView(); }
- else if (e.key === ' ' && !e.target.matches('input, textarea, select')) { e.preventDefault(); const views = ['board', 'stats', 'timeline']; const idx = views.indexOf(state.currentView); switchView(views[(idx + 1) % views.length]); }
+ else if (e.key === ' ' && !e.target.matches('input, textarea, select')) { e.preventDefault(); const views = ['board', 'stats', 'agent']; const idx = views.indexOf(state.currentView); switchView(views[(idx + 1) % views.length]); }
else if (e.key === '1' && !e.target.matches('input, textarea, select')) { switchView('board'); }
else if (e.key === '2' && !e.target.matches('input, textarea, select')) { switchView('stats'); }
- else if (e.key === '3' && !e.target.matches('input, textarea, select')) { switchView('timeline'); }
+ else if (e.key === '3' && !e.target.matches('input, textarea, select')) { switchView('agent'); }
else if (e.key === 't' && !e.target.matches('input, textarea, select')) { switchTheme(); }
else if (e.key === 'r' && !e.target.matches('input, textarea, select')) { refreshData(); }
else if (e.key === 'f' && !e.target.matches('input, textarea, select')) { state.filterVisible = !state.filterVisible; document.getElementById('filter-bar').classList.toggle('visible', state.filterVisible); }
@@ -496,15 +646,12 @@ function applyConfig(config) {
state.theme = config.theme;
document.documentElement.setAttribute('data-theme', config.theme);
}
- if (config.default_view && ['board', 'statistics', 'timeline'].includes(config.default_view)) {
+ if (config.default_view && ['board', 'statistics', 'agent'].includes(config.default_view)) {
switchView(config.default_view === 'statistics' ? 'stats' : config.default_view);
}
if (config.column_width && config.column_width >= 10) {
document.documentElement.style.setProperty('--col-min-width', config.column_width + 'ch');
}
- if (config.show_timelines === false) {
- document.querySelectorAll('.timeline-phase, .timeline-wave').forEach(el => el.style.display = 'none');
- }
}
async function refreshData() {
diff --git a/automaton/dashboard/html/index.html b/automaton/dashboard/html/index.html
index 3d1d366..875dec8 100644
--- a/automaton/dashboard/html/index.html
+++ b/automaton/dashboard/html/index.html
@@ -17,7 +17,7 @@
Board
Stats
- Timeline
+ Agent