feat: add Orchestrator Loop — phased plan execution with team agents

Adds a new autonomous loop that accepts high-level goals, generates
phased execution plans via AI, and executes them step-by-step with
verification gates between phases.

Core components:
- OrchestratorLoop: state machine (idle→planning→approval→executing→verifying→completed)
- OrchestratorPlanner: plan generation via PlanOrchestrator, Kahn's algorithm phase grouping
- OrchestratorVerifier: phase verification (strict/moderate/lenient modes)
- Prompt templates for phase execution, team delegation, verification, replanning

API (10 endpoints):
- POST start/approve/reject/pause/resume/stop
- GET status/plan
- POST phase/:id/skip, phase/:id/retry

Frontend: orchestrator-panel.js with SSE-driven state, phase progress, task tracking

Tests: 22 tests (18 route + 4 unit), all passing. Typecheck/lint/format clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-21 07:20:18 +01:00
co-authored by Claude Opus 4.6
parent 497ca4891a
commit 61b5ec095c
27 changed files with 4718 additions and 1 deletions
+17
View File
@@ -235,6 +235,19 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.PLAN_STARTED, '_onPlanStarted'],
[SSE_EVENTS.PLAN_CANCELLED, '_onPlanCancelled'],
[SSE_EVENTS.PLAN_COMPLETED, '_onPlanCompleted'],
// Orchestrator loop
[SSE_EVENTS.ORCHESTRATOR_STATE_CHANGED, '_onOrchestratorStateChanged'],
[SSE_EVENTS.ORCHESTRATOR_PLAN_READY, '_onOrchestratorPlanReady'],
[SSE_EVENTS.ORCHESTRATOR_PHASE_STARTED, '_onOrchestratorPhaseStarted'],
[SSE_EVENTS.ORCHESTRATOR_PHASE_COMPLETED, '_onOrchestratorPhaseCompleted'],
[SSE_EVENTS.ORCHESTRATOR_PHASE_FAILED, '_onOrchestratorPhaseFailed'],
[SSE_EVENTS.ORCHESTRATOR_VERIFICATION, '_onOrchestratorVerification'],
[SSE_EVENTS.ORCHESTRATOR_TASK_ASSIGNED, '_onOrchestratorTaskAssigned'],
[SSE_EVENTS.ORCHESTRATOR_TASK_COMPLETED, '_onOrchestratorTaskCompleted'],
[SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'],
[SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'],
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
];
// ═══════════════════════════════════════════════════════════════
@@ -317,6 +330,10 @@ class CodemanApp {
this.logViewerWindows = new Map(); // Map<windowId, { element, eventSource, filePath }>
this.logViewerWindowZIndex = ZINDEX_LOG_VIEWER_BASE;
this.projectInsightsPanelVisible = false;
// Orchestrator loop state
this.orchestratorState = null; // { state, plan, currentPhaseIndex, stats }
this.orchestratorPanelVisible = false;
this.currentSessionWorkingDir = null; // Track current session's working dir for path normalization
// Image popup windows (auto-open for detected screenshots/images)
+13
View File
@@ -277,6 +277,19 @@ const SSE_EVENTS = {
PLAN_STARTED: 'plan:started',
PLAN_CANCELLED: 'plan:cancelled',
PLAN_COMPLETED: 'plan:completed',
// Orchestrator Loop
ORCHESTRATOR_STATE_CHANGED: 'orchestrator:stateChanged',
ORCHESTRATOR_PLAN_READY: 'orchestrator:planReady',
ORCHESTRATOR_PHASE_STARTED: 'orchestrator:phaseStarted',
ORCHESTRATOR_PHASE_COMPLETED: 'orchestrator:phaseCompleted',
ORCHESTRATOR_PHASE_FAILED: 'orchestrator:phaseFailed',
ORCHESTRATOR_VERIFICATION: 'orchestrator:verification',
ORCHESTRATOR_TASK_ASSIGNED: 'orchestrator:taskAssigned',
ORCHESTRATOR_TASK_COMPLETED: 'orchestrator:taskCompleted',
ORCHESTRATOR_TASK_FAILED: 'orchestrator:taskFailed',
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
ORCHESTRATOR_ERROR: 'orchestrator:error',
};
// ═══════════════════════════════════════════════════════════════
+12
View File
@@ -137,6 +137,17 @@
</div>
</div>
<!-- Orchestrator Loop Panel -->
<div class="orchestrator-panel" id="orchestratorPanel" style="display: none;">
<div class="orchestrator-header">
<span class="orchestrator-title">Orchestrator</span>
<span class="orchestrator-state-badge" id="orchestratorStateBadge">idle</span>
<div class="orchestrator-actions" id="orchestratorActions"></div>
<button class="orchestrator-close-btn" onclick="app.closeOrchestratorPanel()" title="Close">&times;</button>
</div>
<div class="orchestrator-body" id="orchestratorBody"></div>
</div>
<!-- Ralph / Todo Tracker Panel -->
<div class="ralph-panel collapsed" id="ralphStatePanel" style="display: none;">
<!-- Collapsed Summary Bar -->
@@ -1704,6 +1715,7 @@
<script defer src="terminal-ui.js"></script>
<script defer src="respawn-ui.js"></script>
<script defer src="ralph-panel.js"></script>
<script defer src="orchestrator-panel.js"></script>
<script defer src="settings-ui.js"></script>
<script defer src="panels-ui.js"></script>
<script defer src="session-ui.js"></script>
+456
View File
@@ -0,0 +1,456 @@
/**
* @fileoverview Orchestrator loop panel — plan-based autonomous execution UI.
* Shows orchestrator state, plan phases, task progress, and verification results.
* Provides controls for start, approve, reject, pause, resume, stop, skip, retry.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, this.orchestratorState)
* @dependency constants.js (SSE_EVENTS, escapeHtml)
* @loadorder 9.5 of 16 — loaded after ralph-panel.js, before settings-ui.js
*/
// ═══════════════════════════════════════════════════════════════
// State color/label mappings
// ═══════════════════════════════════════════════════════════════
const ORCH_STATE_COLORS = {
idle: '#6b7280',
planning: '#f59e0b',
approval: '#8b5cf6',
executing: '#3b82f6',
verifying: '#06b6d4',
replanning: '#f97316',
completed: '#22c55e',
failed: '#ef4444',
paused: '#9ca3af',
};
const ORCH_PHASE_STATUS_ICONS = {
pending: '\u25cb', // ○
executing: '\u25d4', // ◔
passed: '\u2713', // ✓
failed: '\u2717', // ✗
skipped: '\u2192', // →
};
Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
// SSE Event Handlers
// ═══════════════════════════════════════════════════════════════
_onOrchestratorStateChanged(data) {
if (!this.orchestratorState) this.orchestratorState = {};
this.orchestratorState.state = data.state;
this.renderOrchestratorPanel();
},
_onOrchestratorPlanReady(data) {
if (!this.orchestratorState) this.orchestratorState = {};
this.orchestratorState.plan = data.plan;
this.orchestratorState.state = 'approval';
this.showOrchestratorPanel();
this.renderOrchestratorPanel();
},
_onOrchestratorPhaseStarted(data) {
if (!this.orchestratorState) return;
this._updateOrchestratorPhase(data.phase);
this.renderOrchestratorPanel();
},
_onOrchestratorPhaseCompleted(data) {
if (!this.orchestratorState) return;
this._updateOrchestratorPhase(data.phase);
this.renderOrchestratorPanel();
},
_onOrchestratorPhaseFailed(data) {
if (!this.orchestratorState) return;
this._updateOrchestratorPhase(data.phase);
this.renderOrchestratorPanel();
},
_onOrchestratorVerification(data) {
if (!this.orchestratorState) return;
// Store verification result on the phase
if (this.orchestratorState.plan) {
const phase = this.orchestratorState.plan.phases.find(p => p.id === data.phaseId);
if (phase) phase._lastVerification = data.result;
}
this.renderOrchestratorPanel();
},
_onOrchestratorTaskAssigned(data) {
if (!this.orchestratorState) return;
this._updateOrchestratorTask(data.task);
this.renderOrchestratorPanel();
},
_onOrchestratorTaskCompleted(data) {
if (!this.orchestratorState) return;
this._updateOrchestratorTask(data.task);
this.renderOrchestratorPanel();
},
_onOrchestratorTaskFailed(data) {
if (!this.orchestratorState) return;
this._updateOrchestratorTask(data.task);
this.renderOrchestratorPanel();
},
_onOrchestratorCompleted(data) {
if (!this.orchestratorState) this.orchestratorState = {};
this.orchestratorState.state = 'completed';
this.orchestratorState.stats = data.stats;
this.renderOrchestratorPanel();
},
_onOrchestratorError(data) {
if (!this.orchestratorState) this.orchestratorState = {};
this.orchestratorState.state = 'failed';
this.orchestratorState.lastError = data.error;
this.renderOrchestratorPanel();
},
// ═══════════════════════════════════════════════════════════════
// Internal helpers
// ═══════════════════════════════════════════════════════════════
_updateOrchestratorPhase(updatedPhase) {
if (!this.orchestratorState?.plan) return;
const idx = this.orchestratorState.plan.phases.findIndex(p => p.id === updatedPhase.id);
if (idx >= 0) this.orchestratorState.plan.phases[idx] = updatedPhase;
},
_updateOrchestratorTask(updatedTask) {
if (!this.orchestratorState?.plan) return;
for (const phase of this.orchestratorState.plan.phases) {
const idx = phase.tasks.findIndex(t => t.id === updatedTask.id);
if (idx >= 0) {
phase.tasks[idx] = updatedTask;
return;
}
}
},
// ═══════════════════════════════════════════════════════════════
// Panel visibility
// ═══════════════════════════════════════════════════════════════
showOrchestratorPanel() {
this.orchestratorPanelVisible = true;
const panel = document.getElementById('orchestratorPanel');
if (panel) panel.style.display = '';
this.renderOrchestratorPanel();
},
closeOrchestratorPanel() {
this.orchestratorPanelVisible = false;
const panel = document.getElementById('orchestratorPanel');
if (panel) panel.style.display = 'none';
},
toggleOrchestratorPanel() {
if (this.orchestratorPanelVisible) {
this.closeOrchestratorPanel();
} else {
this.showOrchestratorPanel();
}
},
// ═══════════════════════════════════════════════════════════════
// API calls
// ═══════════════════════════════════════════════════════════════
async orchestratorStart(goal, config) {
try {
const res = await fetch('/api/orchestrator/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ goal, config }),
});
const data = await res.json();
if (data.ok) {
this.orchestratorState = { state: 'planning', plan: null };
this.showOrchestratorPanel();
this.renderOrchestratorPanel();
}
return data;
} catch (err) {
console.error('[Orchestrator] Start failed:', err);
}
},
async orchestratorApprove() {
try {
await fetch('/api/orchestrator/approve', { method: 'POST' });
} catch (err) {
console.error('[Orchestrator] Approve failed:', err);
}
},
async orchestratorReject(feedback) {
try {
await fetch('/api/orchestrator/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feedback }),
});
} catch (err) {
console.error('[Orchestrator] Reject failed:', err);
}
},
async orchestratorPause() {
try {
await fetch('/api/orchestrator/pause', { method: 'POST' });
} catch (err) {
console.error('[Orchestrator] Pause failed:', err);
}
},
async orchestratorResume() {
try {
await fetch('/api/orchestrator/resume', { method: 'POST' });
} catch (err) {
console.error('[Orchestrator] Resume failed:', err);
}
},
async orchestratorStop() {
try {
await fetch('/api/orchestrator/stop', { method: 'POST' });
this.orchestratorState = { state: 'idle' };
this.renderOrchestratorPanel();
} catch (err) {
console.error('[Orchestrator] Stop failed:', err);
}
},
async orchestratorSkipPhase(phaseId) {
try {
await fetch(`/api/orchestrator/phase/${phaseId}/skip`, { method: 'POST' });
} catch (err) {
console.error('[Orchestrator] Skip failed:', err);
}
},
async orchestratorRetryPhase(phaseId) {
try {
await fetch(`/api/orchestrator/phase/${phaseId}/retry`, { method: 'POST' });
} catch (err) {
console.error('[Orchestrator] Retry failed:', err);
}
},
async refreshOrchestratorStatus() {
try {
const res = await fetch('/api/orchestrator/status');
const data = await res.json();
if (data.ok) {
this.orchestratorState = data;
this.renderOrchestratorPanel();
}
} catch (err) {
console.error('[Orchestrator] Status fetch failed:', err);
}
},
// ═══════════════════════════════════════════════════════════════
// Rendering
// ═══════════════════════════════════════════════════════════════
renderOrchestratorPanel() {
const panel = document.getElementById('orchestratorPanel');
if (!panel) return;
const state = this.orchestratorState?.state || 'idle';
const plan = this.orchestratorState?.plan;
// Update badge
const badge = document.getElementById('orchestratorStateBadge');
if (badge) {
badge.textContent = state;
badge.style.background = ORCH_STATE_COLORS[state] || '#6b7280';
}
// Update action buttons
const actions = document.getElementById('orchestratorActions');
if (actions) {
actions.innerHTML = this._renderOrchestratorActions(state);
}
// Update body
const body = document.getElementById('orchestratorBody');
if (body) {
body.innerHTML = this._renderOrchestratorBody(state, plan);
}
},
_renderOrchestratorActions(state) {
const btn = (label, onclick, cls = '') =>
`<button class="orch-btn ${cls}" onclick="${onclick}">${label}</button>`;
switch (state) {
case 'idle':
case 'completed':
case 'failed':
return btn('New Goal', 'app.promptOrchestratorGoal()', 'orch-btn-primary');
case 'planning':
return btn('Cancel', 'app.orchestratorStop()', 'orch-btn-danger');
case 'approval':
return [
btn('Approve', 'app.orchestratorApprove()', 'orch-btn-primary'),
btn('Reject', 'app.promptOrchestratorReject()', 'orch-btn-warn'),
btn('Cancel', 'app.orchestratorStop()', 'orch-btn-danger'),
].join('');
case 'executing':
case 'verifying':
case 'replanning':
return [
btn('Pause', 'app.orchestratorPause()'),
btn('Stop', 'app.orchestratorStop()', 'orch-btn-danger'),
].join('');
case 'paused':
return [
btn('Resume', 'app.orchestratorResume()', 'orch-btn-primary'),
btn('Stop', 'app.orchestratorStop()', 'orch-btn-danger'),
].join('');
default:
return '';
}
},
_renderOrchestratorBody(state, plan) {
if (state === 'idle' && !plan) {
return '<div class="orch-empty">No orchestration active. Click "New Goal" to start.</div>';
}
if (state === 'planning') {
return '<div class="orch-planning"><div class="orch-spinner"></div>Generating plan...</div>';
}
if (!plan) return '';
const parts = [];
// Goal
parts.push(`<div class="orch-goal"><strong>Goal:</strong> ${escapeHtml(plan.goal.slice(0, 200))}</div>`);
// Progress summary
const completed = plan.phases.filter(p => p.status === 'passed' || p.status === 'skipped').length;
const total = plan.phases.length;
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
parts.push(`<div class="orch-progress-bar"><div class="orch-progress-fill" style="width:${pct}%"></div><span>${completed}/${total} phases</span></div>`);
// Phase list
parts.push('<div class="orch-phases">');
for (const phase of plan.phases) {
parts.push(this._renderOrchestratorPhase(phase, state));
}
parts.push('</div>');
// Stats (if completed/failed)
if (state === 'completed' || state === 'failed') {
const stats = this.orchestratorState?.stats;
if (stats) {
parts.push(this._renderOrchestratorStats(stats));
}
if (this.orchestratorState?.lastError) {
parts.push(`<div class="orch-error">Error: ${escapeHtml(this.orchestratorState.lastError)}</div>`);
}
}
return parts.join('');
},
_renderOrchestratorPhase(phase, orchState) {
const icon = ORCH_PHASE_STATUS_ICONS[phase.status] || '\u25cb';
const isActive = phase.status === 'executing';
const cls = `orch-phase ${isActive ? 'orch-phase-active' : ''} orch-phase-${phase.status}`;
let actions = '';
if (orchState === 'executing' || orchState === 'failed') {
if (phase.status === 'pending') {
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase('${phase.id}')" title="Skip">skip</button>`;
}
if (phase.status === 'failed') {
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase('${phase.id}')" title="Retry">retry</button>`;
}
}
// Task summary
const tasksDone = phase.tasks.filter(t => t.status === 'completed').length;
const tasksFailed = phase.tasks.filter(t => t.status === 'failed').length;
const tasksTotal = phase.tasks.length;
const taskSummary = `${tasksDone}/${tasksTotal}${tasksFailed > 0 ? ` (${tasksFailed} failed)` : ''}`;
// Duration
let duration = '';
if (phase.durationMs) {
const secs = Math.round(phase.durationMs / 1000);
duration = secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`;
}
let html = `<div class="${cls}">`;
html += `<div class="orch-phase-header">`;
html += `<span class="orch-phase-icon">${icon}</span>`;
html += `<span class="orch-phase-name">${escapeHtml(phase.name)}</span>`;
html += `<span class="orch-phase-tasks">${taskSummary}</span>`;
if (duration) html += `<span class="orch-phase-duration">${duration}</span>`;
if (actions) html += `<span class="orch-phase-actions">${actions}</span>`;
html += `</div>`;
// Expanded task list for active phase
if (isActive || phase.status === 'failed') {
html += '<div class="orch-phase-tasks-list">';
for (const task of phase.tasks) {
const taskIcon = ORCH_PHASE_STATUS_ICONS[task.status] || '\u25cb';
const taskCls = `orch-task orch-task-${task.status}`;
html += `<div class="${taskCls}"><span class="orch-task-icon">${taskIcon}</span>`;
html += `<span class="orch-task-prompt">${escapeHtml(task.prompt.slice(0, 100))}</span>`;
if (task.error) html += `<span class="orch-task-error">${escapeHtml(task.error.slice(0, 80))}</span>`;
html += '</div>';
}
html += '</div>';
}
// Verification result
if (phase._lastVerification) {
const v = phase._lastVerification;
const vCls = v.passed ? 'orch-verify-pass' : 'orch-verify-fail';
html += `<div class="${vCls}">${v.passed ? 'Verified' : 'Failed'}: ${escapeHtml(v.summary || '')}</div>`;
}
html += '</div>';
return html;
},
_renderOrchestratorStats(stats) {
return `<div class="orch-stats">
<span>Phases: ${stats.phasesCompleted} done, ${stats.phasesFailed} failed</span>
<span>Tasks: ${stats.totalTasksCompleted} done, ${stats.totalTasksFailed} failed</span>
${stats.replanCount > 0 ? `<span>Replans: ${stats.replanCount}</span>` : ''}
${stats.totalDurationMs ? `<span>Duration: ${Math.round(stats.totalDurationMs / 60000)}m</span>` : ''}
</div>`;
},
// ═══════════════════════════════════════════════════════════════
// User prompts
// ═══════════════════════════════════════════════════════════════
promptOrchestratorGoal() {
const goal = prompt('Enter your goal for the orchestrator:');
if (goal && goal.trim()) {
this.orchestratorStart(goal.trim());
}
},
promptOrchestratorReject() {
const feedback = prompt('Feedback on the plan (what should change?):');
if (feedback && feedback.trim()) {
this.orchestratorReject(feedback.trim());
}
},
});
+219
View File
@@ -7540,3 +7540,222 @@ kbd {
color: #495057;
font-size: 12px;
}
/* ═══════════════════════════════════════════════════════════════
Orchestrator Panel
═══════════════════════════════════════════════════════════════ */
.orchestrator-panel {
background: var(--bg-card);
border-bottom: 1px solid var(--border);
font-size: 0.75rem;
flex-shrink: 0;
overflow: hidden;
position: relative;
contain: layout style paint;
}
.orchestrator-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
.orchestrator-title {
font-weight: 600;
color: var(--text-primary);
font-size: 0.8rem;
}
.orchestrator-state-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
color: #fff;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.orchestrator-actions {
display: flex;
gap: 4px;
margin-left: auto;
}
.orchestrator-close-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 1rem;
padding: 0 4px;
line-height: 1;
}
.orchestrator-close-btn:hover { color: var(--text-primary); }
.orch-btn {
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-input);
color: var(--text-primary);
cursor: pointer;
font-size: 0.7rem;
}
.orch-btn:hover { background: var(--bg-hover); }
.orch-btn-primary { background: #3b82f6; color: #fff; border-color: #3b82f6; }
.orch-btn-primary:hover { background: #2563eb; }
.orch-btn-warn { background: #f59e0b; color: #000; border-color: #f59e0b; }
.orch-btn-warn:hover { background: #d97706; }
.orch-btn-danger { background: #ef4444; color: #fff; border-color: #ef4444; }
.orch-btn-danger:hover { background: #dc2626; }
.orchestrator-body {
max-height: 300px;
overflow-y: auto;
padding: 8px 10px;
}
.orch-empty {
color: var(--text-secondary);
text-align: center;
padding: 12px;
}
.orch-planning {
display: flex;
align-items: center;
gap: 8px;
color: #f59e0b;
padding: 12px;
}
.orch-spinner {
width: 14px;
height: 14px;
border: 2px solid #f59e0b33;
border-top-color: #f59e0b;
border-radius: 50%;
animation: orch-spin 0.8s linear infinite;
}
@keyframes orch-spin { to { transform: rotate(360deg); } }
.orch-goal {
margin-bottom: 6px;
color: var(--text-secondary);
line-height: 1.3;
}
.orch-progress-bar {
position: relative;
height: 18px;
background: var(--bg-input);
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.orch-progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, #3b82f6, #22c55e);
transition: width 0.3s ease;
border-radius: 4px;
}
.orch-progress-bar span {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-primary);
font-size: 0.65rem;
font-weight: 600;
z-index: 1;
}
.orch-phases {
display: flex;
flex-direction: column;
gap: 2px;
}
.orch-phase {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
}
.orch-phase-active { border-color: #3b82f6; }
.orch-phase-passed { opacity: 0.7; }
.orch-phase-skipped { opacity: 0.5; }
.orch-phase-failed { border-color: #ef4444; }
.orch-phase-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--bg-secondary);
}
.orch-phase-icon { width: 14px; text-align: center; }
.orch-phase-name { flex: 1; color: var(--text-primary); font-weight: 500; }
.orch-phase-tasks { color: var(--text-secondary); font-size: 0.65rem; }
.orch-phase-duration { color: var(--text-secondary); font-size: 0.65rem; }
.orch-phase-actions { display: flex; gap: 2px; }
.orch-phase-btn {
padding: 1px 6px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg-input);
color: var(--text-secondary);
cursor: pointer;
font-size: 0.6rem;
}
.orch-phase-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
.orch-phase-tasks-list {
padding: 4px 8px 4px 26px;
border-top: 1px solid var(--border);
}
.orch-task {
display: flex;
align-items: flex-start;
gap: 4px;
padding: 2px 0;
line-height: 1.3;
}
.orch-task-icon { width: 12px; text-align: center; flex-shrink: 0; margin-top: 1px; }
.orch-task-prompt { color: var(--text-primary); word-break: break-word; }
.orch-task-error { color: #ef4444; font-size: 0.6rem; margin-left: auto; }
.orch-task-completed .orch-task-prompt { color: var(--text-secondary); }
.orch-task-failed .orch-task-icon { color: #ef4444; }
.orch-verify-pass { color: #22c55e; padding: 2px 8px; font-size: 0.65rem; }
.orch-verify-fail { color: #ef4444; padding: 2px 8px; font-size: 0.65rem; }
.orch-stats {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 6px 0;
margin-top: 6px;
border-top: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.65rem;
}
.orch-error {
color: #ef4444;
padding: 4px 0;
margin-top: 4px;
font-size: 0.7rem;
}