diff --git a/src/web/server.ts b/src/web/server.ts
index 83f88298..c04191d2 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -36,6 +36,13 @@ import { v4 as uuidv4 } from 'uuid';
import { createRequire } from 'node:module';
import { RunSummaryTracker } from '../run-summary.js';
import { PlanOrchestrator, type DetailedPlanResult } from '../plan-orchestrator.js';
+import {
+ ExecutionBridge,
+ getExecutionBridge,
+ type PlanItem as ExecutionPlanItem,
+ type AgentSpawner,
+} from '../execution-bridge.js';
+import { type ModelConfig } from '../model-selector.js';
// Load version from package.json
const require = createRequire(import.meta.url);
@@ -311,6 +318,8 @@ export class WebServer extends EventEmitter {
private pendingRespawnStarts: Map = new Map();
// Active plan orchestrators (for cancellation via API)
private activePlanOrchestrators: Map = new Map();
+ // Execution bridge for parallel task execution
+ private executionBridge: ExecutionBridge;
// Grace period before starting restored respawn controllers (2 minutes)
private static readonly RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000;
@@ -345,6 +354,10 @@ export class WebServer extends EventEmitter {
this.spawnOrchestrator = new SpawnOrchestrator();
this.setupSpawnOrchestratorListeners();
+ // Initialize execution bridge with model config from settings
+ this.executionBridge = getExecutionBridge(this.loadModelConfig());
+ this.setupExecutionBridgeListeners();
+
// Set up subagent watcher listeners
this.setupSubagentWatcherListeners();
@@ -1801,6 +1814,38 @@ export class WebServer extends EventEmitter {
};
});
+ // Toggle image watcher for a session
+ this.app.post('/api/sessions/:id/image-watcher', async (req) => {
+ const { id } = req.params as { id: string };
+ const body = req.body as { enabled: boolean };
+ const session = this.sessions.get(id);
+
+ if (!session) {
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
+ }
+
+ if (body.enabled === undefined) {
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'enabled field is required');
+ }
+
+ if (body.enabled) {
+ imageWatcher.watchSession(session.id, session.workingDir);
+ } else {
+ imageWatcher.unwatchSession(session.id);
+ }
+
+ // Store state on session for persistence
+ session.imageWatcherEnabled = body.enabled;
+ this.persistSessionState(session);
+
+ return {
+ success: true,
+ data: {
+ imageWatcherEnabled: body.enabled,
+ },
+ };
+ });
+
// Quick run (create session, run prompt, return result, then cleanup)
this.app.post('/api/run', async (req) => {
// Prevent unbounded session creation
@@ -2837,6 +2882,22 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
console.log('Subagent watcher stopped via settings change');
}
+ // Handle image watcher toggle dynamically
+ const imageWatcherEnabled = settings.imageWatcherEnabled ?? true;
+ if (imageWatcherEnabled && !imageWatcher.isRunning()) {
+ imageWatcher.start();
+ // Re-watch all active sessions that have image watcher enabled
+ for (const session of this.sessions.values()) {
+ if (session.imageWatcherEnabled) {
+ imageWatcher.watchSession(session.id, session.workingDir);
+ }
+ }
+ console.log('Image watcher started via settings change');
+ } else if (!imageWatcherEnabled && imageWatcher.isRunning()) {
+ imageWatcher.stop();
+ console.log('Image watcher stopped via settings change');
+ }
+
return { success: true };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
@@ -3045,6 +3106,148 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return { success: true, data: { agentId } };
});
+ // ========== Execution Bridge Endpoints ==========
+
+ // Get execution status
+ this.app.get('/api/execution/status', async () => {
+ return {
+ success: true,
+ data: {
+ status: this.executionBridge.status,
+ progress: this.executionBridge.getProgress(),
+ },
+ };
+ });
+
+ // Get execution schedule (the current plan)
+ this.app.get('/api/execution/schedule', async () => {
+ const schedule = this.executionBridge['_scheduler'].schedule;
+ return { success: true, data: schedule };
+ });
+
+ // Load a plan for execution
+ // Accepts either ExecutionPlanItem format (id, title, description) or
+ // PlanOrchestrator PlanItem format (id, content) and converts as needed
+ this.app.post('/api/execution/load', async (req) => {
+ const { items, workingDir } = req.body as {
+ items: Array<{
+ id?: string;
+ title?: string;
+ description?: string;
+ content?: string;
+ parallelGroup?: number;
+ agentType?: string;
+ recommendedModel?: string;
+ requiresFreshContext?: boolean;
+ estimatedTokens?: number;
+ inputFiles?: string[];
+ outputFiles?: string[];
+ dependencies?: string[];
+ }>;
+ workingDir?: string;
+ };
+ if (!items || !Array.isArray(items)) {
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'items array is required');
+ }
+ try {
+ if (workingDir) {
+ this.executionBridge.setWorkingDir(workingDir);
+ }
+ // Convert to ExecutionPlanItem format
+ const execItems: ExecutionPlanItem[] = items.map((item, idx) => ({
+ id: item.id || `task-${idx}`,
+ title: item.title || item.content?.slice(0, 50) || `Task ${idx + 1}`,
+ description: item.description || item.content || '',
+ parallelGroup: item.parallelGroup,
+ agentType: item.agentType,
+ recommendedModel: item.recommendedModel,
+ requiresFreshContext: item.requiresFreshContext,
+ estimatedTokens: item.estimatedTokens,
+ inputFiles: item.inputFiles,
+ outputFiles: item.outputFiles,
+ dependencies: item.dependencies,
+ }));
+ const schedule = this.executionBridge.loadPlan(execItems);
+ return { success: true, data: schedule };
+ } catch (err) {
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
+ }
+ });
+
+ // Start execution
+ this.app.post('/api/execution/start', async () => {
+ try {
+ await this.executionBridge.start();
+ return { success: true };
+ } catch (err) {
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
+ }
+ });
+
+ // Pause execution
+ this.app.post('/api/execution/pause', async () => {
+ this.executionBridge.pause();
+ return { success: true };
+ });
+
+ // Resume execution
+ this.app.post('/api/execution/resume', async () => {
+ this.executionBridge.resume();
+ return { success: true };
+ });
+
+ // Cancel execution
+ this.app.post('/api/execution/cancel', async (req) => {
+ const { reason } = (req.body as { reason?: string }) || {};
+ await this.executionBridge.cancel(reason || 'Cancelled via API');
+ return { success: true };
+ });
+
+ // Reset execution bridge
+ this.app.post('/api/execution/reset', async () => {
+ this.executionBridge.reset();
+ return { success: true };
+ });
+
+ // Get execution history
+ this.app.get('/api/execution/history', async () => {
+ return { success: true, data: this.executionBridge.getHistory() };
+ });
+
+ // Get model configuration
+ this.app.get('/api/execution/model-config', async () => {
+ return { success: true, data: this.executionBridge.getModelConfig() };
+ });
+
+ // Update model configuration
+ this.app.put('/api/execution/model-config', async (req) => {
+ const config = req.body as Partial;
+ try {
+ this.executionBridge.updateModelConfig(config);
+ const fullConfig = this.executionBridge.getModelConfig();
+ this.saveModelConfig(fullConfig);
+ this.broadcast('execution:modelConfigUpdated', fullConfig);
+ return { success: true, data: fullConfig };
+ } catch (err) {
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
+ }
+ });
+
+ // Mark task complete (external signal)
+ this.app.post('/api/execution/tasks/:taskId/complete', async (req) => {
+ const { taskId } = req.params as { taskId: string };
+ this.executionBridge.markTaskComplete(taskId);
+ return { success: true };
+ });
+
+ // Mark task failed (external signal)
+ this.app.post('/api/execution/tasks/:taskId/failed', async (req) => {
+ const { taskId } = req.params as { taskId: string };
+ const { error } = (req.body as { error?: string }) || {};
+ this.executionBridge.markTaskFailed(taskId, error || 'Failed via API');
+ return { success: true };
+ });
+
// ========== Subagent Monitoring (Claude Code Background Agents) ==========
// List all known subagents
@@ -3457,8 +3660,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Set working directory for Ralph tracker to auto-load @fix_plan.md
session.ralphTracker.setWorkingDir(session.workingDir);
- // Start watching for new images in this session's working directory
- imageWatcher.watchSession(session.id, session.workingDir);
+ // Start watching for new images in this session's working directory (if enabled globally and per-session)
+ if (this.isImageWatcherEnabled() && session.imageWatcherEnabled) {
+ imageWatcher.watchSession(session.id, session.workingDir);
+ }
session.on('output', (data) => {
// Use batching for better performance at high throughput
@@ -3843,6 +4048,193 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.spawnOrchestrator.on('stateUpdate', (data) => this.broadcast('spawn:stateUpdate', data));
}
+ /**
+ * Load model configuration from settings file.
+ */
+ private loadModelConfig(): Partial {
+ const settingsPath = join(homedir(), '.claudeman', 'settings.json');
+ try {
+ if (existsSync(settingsPath)) {
+ const content = readFileSync(settingsPath, 'utf-8');
+ const settings = JSON.parse(content);
+ return settings.modelConfig || {};
+ }
+ } catch (err) {
+ console.error('Failed to load model config:', getErrorMessage(err));
+ }
+ return {};
+ }
+
+ /**
+ * Save model configuration to settings file.
+ */
+ private saveModelConfig(config: ModelConfig): void {
+ const settingsPath = join(homedir(), '.claudeman', 'settings.json');
+ try {
+ const dir = dirname(settingsPath);
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
+ }
+ let settings: Record = {};
+ if (existsSync(settingsPath)) {
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
+ }
+ settings.modelConfig = config;
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
+ } catch (err) {
+ console.error('Failed to save model config:', getErrorMessage(err));
+ }
+ }
+
+ /**
+ * Set up event listeners for execution bridge.
+ * Broadcasts execution progress to SSE clients.
+ */
+ private setupExecutionBridgeListeners(): void {
+ // Create agent spawner interface
+ const spawner: AgentSpawner = {
+ spawnAgentWithModel: async (taskId, workingDir, prompt, _model, _options) => {
+ // Note: model and options are reserved for future model selection integration
+ const globalNice = this.getGlobalNiceConfig();
+ const session = new Session({
+ workingDir,
+ screenManager: this.screenManager,
+ useScreen: true,
+ mode: 'claude',
+ name: `exec:${taskId}`,
+ niceConfig: globalNice,
+ });
+
+ this.sessions.set(session.id, session);
+ this.store.incrementSessionsCreated();
+ this.setupSessionListeners(session);
+
+ await session.startInteractive();
+ this.broadcast('session:created', session.toDetailedState());
+ this.broadcast('session:interactive', { id: session.id });
+ this.persistSessionState(session);
+
+ // Configure ralph tracker for completion detection
+ session.ralphTracker.enable();
+
+ // Set up completion listener for execution bridge
+ session.on('ralphCompletionDetected', () => {
+ this.executionBridge.markTaskComplete(taskId);
+ });
+
+ // Send the prompt
+ setTimeout(() => {
+ session.writeViaScreen(prompt + '\r');
+ }, 2000);
+
+ return { sessionId: session.id };
+ },
+ useTaskTool: async (_sessionId, _taskId, _prompt, _model) => {
+ // Task tool mode - not yet implemented, falls back to session mode
+ throw new Error('Task tool mode not yet implemented');
+ },
+ isTaskComplete: (taskId) => {
+ // Check if task is marked complete in scheduler
+ const schedule = this.executionBridge['_scheduler'].schedule;
+ if (!schedule) return false;
+ for (const group of schedule.groups) {
+ const task = group.tasks.find(t => t.id === taskId);
+ if (task) return task.status === 'completed';
+ }
+ return false;
+ },
+ getTaskResult: (taskId) => {
+ const schedule = this.executionBridge['_scheduler'].schedule;
+ if (!schedule) return null;
+ for (const group of schedule.groups) {
+ const task = group.tasks.find(t => t.id === taskId);
+ if (task) {
+ if (task.status === 'completed') {
+ return { success: true };
+ } else if (task.status === 'failed') {
+ return { success: false, error: task.error };
+ }
+ }
+ }
+ return null;
+ },
+ };
+
+ this.executionBridge.setAgentSpawner(spawner);
+
+ // Set up session writer for context management
+ this.executionBridge.setSessionWriter({
+ writeToSession: (sessionId, text) => {
+ const session = this.sessions.get(sessionId);
+ if (session) {
+ session.writeViaScreen(text);
+ }
+ },
+ createSession: async (workingDir, name) => {
+ const globalNice = this.getGlobalNiceConfig();
+ const session = new Session({
+ workingDir,
+ screenManager: this.screenManager,
+ useScreen: true,
+ mode: 'claude',
+ name: name || 'exec-context',
+ niceConfig: globalNice,
+ });
+ this.sessions.set(session.id, session);
+ this.store.incrementSessionsCreated();
+ this.setupSessionListeners(session);
+ await session.startInteractive();
+ this.broadcast('session:created', session.toDetailedState());
+ this.persistSessionState(session);
+ return { sessionId: session.id };
+ },
+ });
+
+ // Forward execution bridge events as SSE broadcasts
+ this.executionBridge.on('planLoaded', (schedule) => {
+ this.broadcast('execution:planLoaded', { schedule });
+ });
+ this.executionBridge.on('started', () => {
+ this.broadcast('execution:started', {});
+ });
+ this.executionBridge.on('paused', () => {
+ this.broadcast('execution:paused', {});
+ });
+ this.executionBridge.on('resumed', () => {
+ this.broadcast('execution:resumed', {});
+ });
+ this.executionBridge.on('completed', (data) => {
+ this.broadcast('execution:completed', data);
+ });
+ this.executionBridge.on('cancelled', (reason) => {
+ this.broadcast('execution:cancelled', { reason });
+ });
+ this.executionBridge.on('groupStarted', (data) => {
+ this.broadcast('execution:groupStarted', data);
+ });
+ this.executionBridge.on('groupCompleted', (data) => {
+ this.broadcast('execution:groupCompleted', data);
+ });
+ this.executionBridge.on('taskAssigned', (data) => {
+ this.broadcast('execution:taskAssigned', data);
+ });
+ this.executionBridge.on('taskCompleted', (data) => {
+ this.broadcast('execution:taskCompleted', data);
+ });
+ this.executionBridge.on('taskFailed', (data) => {
+ this.broadcast('execution:taskFailed', data);
+ });
+ this.executionBridge.on('freshContext', (data) => {
+ this.broadcast('execution:freshContext', data);
+ });
+ this.executionBridge.on('modelSelected', (data) => {
+ this.broadcast('execution:modelSelected', data);
+ });
+ this.executionBridge.on('progress', (progress) => {
+ this.broadcast('execution:progress', progress);
+ });
+ }
+
private setupTimedRespawn(sessionId: string, durationMinutes: number): void {
// Clear existing timer if any
const existing = this.respawnTimers.get(sessionId);
@@ -4489,9 +4881,13 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
console.log('Subagent watcher disabled by user settings');
}
- // Start image watcher for auto-popup of screenshots
- imageWatcher.start();
- console.log('Image watcher started - monitoring session directories for new images');
+ // Start image watcher for auto-popup of screenshots (if enabled)
+ if (this.isImageWatcherEnabled()) {
+ imageWatcher.start();
+ console.log('Image watcher started - monitoring session directories for new images');
+ } else {
+ console.log('Image watcher disabled by user settings');
+ }
}
/**
@@ -4512,6 +4908,24 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return true; // Default enabled
}
+ /**
+ * Check if image watcher is enabled in settings (default: true)
+ */
+ private isImageWatcherEnabled(): boolean {
+ const settingsPath = join(homedir(), '.claudeman', 'settings.json');
+ try {
+ if (existsSync(settingsPath)) {
+ const content = readFileSync(settingsPath, 'utf-8');
+ const settings = JSON.parse(content);
+ // Default to true if not explicitly set
+ return settings.imageWatcherEnabled ?? true;
+ }
+ } catch (err) {
+ console.error('Failed to read image watcher setting:', err);
+ }
+ return true; // Default enabled
+ }
+
private async restoreScreenSessions(): Promise {
try {
// Reconcile screens to find which ones are still alive (also discovers unknown screens)
diff --git a/test/execution-bridge.test.ts b/test/execution-bridge.test.ts
new file mode 100644
index 00000000..4a8a2116
--- /dev/null
+++ b/test/execution-bridge.test.ts
@@ -0,0 +1,270 @@
+/**
+ * @fileoverview Tests for the Execution Bridge system.
+ *
+ * Tests the core functionality of:
+ * - ModelSelector
+ * - GroupScheduler
+ * - ExecutionBridge
+ *
+ * Uses mocks to avoid spawning real Claude sessions.
+ *
+ * Port: none (unit tests, no server)
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import {
+ ModelSelector,
+ resetModelSelector,
+ createDefaultModelConfig,
+ type ModelConfig,
+ type AgentType,
+} from '../src/model-selector.js';
+import {
+ GroupScheduler,
+ resetGroupScheduler,
+ type GroupTask,
+} from '../src/group-scheduler.js';
+import {
+ ExecutionBridge,
+ resetExecutionBridge,
+ type PlanItem,
+} from '../src/execution-bridge.js';
+
+describe('ModelSelector', () => {
+ let selector: ModelSelector;
+
+ beforeEach(() => {
+ resetModelSelector();
+ selector = new ModelSelector();
+ });
+
+ afterEach(() => {
+ resetModelSelector();
+ });
+
+ it('should use default model when no overrides', () => {
+ const selection = selector.selectModel('task-1', { agentType: 'explore' });
+ expect(selection.model).toBe('sonnet');
+ expect(selection.usedUserDefault).toBe(true);
+ });
+
+ it('should respect user default model', () => {
+ selector.updateConfig({ defaultModel: 'opus' });
+ const selection = selector.selectModel('task-1', { agentType: 'explore' });
+ expect(selection.model).toBe('opus');
+ expect(selection.usedUserDefault).toBe(true);
+ });
+
+ it('should use agent type override when set', () => {
+ selector.updateConfig({
+ agentTypeOverrides: { explore: 'haiku' },
+ });
+ const selection = selector.selectModel('task-1', { agentType: 'explore' });
+ expect(selection.model).toBe('haiku');
+ expect(selection.usedUserDefault).toBe(false);
+ });
+
+ it('should include optimizer recommendation when different', () => {
+ selector.updateConfig({ defaultModel: 'opus' });
+ const selection = selector.selectModel('task-1', {
+ agentType: 'explore',
+ recommendedModel: 'haiku',
+ });
+ expect(selection.model).toBe('opus');
+ expect(selection.optimizerRecommendation).toBe('haiku');
+ });
+
+ it('should select session mode for high token tasks', () => {
+ const mode = selector.selectExecutionMode({ estimatedTokens: 60000 });
+ expect(mode.mode).toBe('session');
+ });
+
+ it('should select task-tool mode for low token explore tasks', () => {
+ const mode = selector.selectExecutionMode({
+ estimatedTokens: 10000,
+ agentType: 'explore',
+ });
+ expect(mode.mode).toBe('task-tool');
+ });
+
+ it('should return correct cost multipliers', () => {
+ expect(selector.getModelCostMultiplier('opus')).toBe(5.0);
+ expect(selector.getModelCostMultiplier('sonnet')).toBe(1.0);
+ expect(selector.getModelCostMultiplier('haiku')).toBe(0.04);
+ });
+});
+
+describe('GroupScheduler', () => {
+ let scheduler: GroupScheduler;
+
+ beforeEach(() => {
+ resetGroupScheduler();
+ scheduler = new GroupScheduler();
+ });
+
+ afterEach(() => {
+ resetGroupScheduler();
+ });
+
+ it('should build schedule from plan items', () => {
+ const items: PlanItem[] = [
+ { id: 't1', title: 'Task 1', description: 'Desc 1', parallelGroup: 0 },
+ { id: 't2', title: 'Task 2', description: 'Desc 2', parallelGroup: 0 },
+ { id: 't3', title: 'Task 3', description: 'Desc 3', parallelGroup: 1 },
+ ];
+
+ const schedule = scheduler.buildSchedule(items);
+
+ expect(schedule.groups).toHaveLength(2);
+ expect(schedule.groups[0].tasks).toHaveLength(2);
+ expect(schedule.groups[1].tasks).toHaveLength(1);
+ expect(schedule.totalTasks).toBe(3);
+ });
+
+ it('should order groups by number', () => {
+ const items: PlanItem[] = [
+ { id: 't1', title: 'Task 1', description: 'Desc 1', parallelGroup: 2 },
+ { id: 't2', title: 'Task 2', description: 'Desc 2', parallelGroup: 0 },
+ { id: 't3', title: 'Task 3', description: 'Desc 3', parallelGroup: 1 },
+ ];
+
+ const schedule = scheduler.buildSchedule(items);
+
+ expect(schedule.groups[0].groupNumber).toBe(0);
+ expect(schedule.groups[1].groupNumber).toBe(1);
+ expect(schedule.groups[2].groupNumber).toBe(2);
+ });
+
+ it('should track group dependencies', () => {
+ const items: PlanItem[] = [
+ { id: 't1', title: 'Task 1', description: 'Desc 1', parallelGroup: 0 },
+ { id: 't2', title: 'Task 2', description: 'Desc 2', parallelGroup: 1, dependencies: ['t1'] },
+ ];
+
+ const schedule = scheduler.buildSchedule(items);
+
+ expect(schedule.groups[1].dependsOnGroups).toContain(0);
+ });
+
+ it('should return first group as ready initially', () => {
+ const items: PlanItem[] = [
+ { id: 't1', title: 'Task 1', description: 'Desc 1', parallelGroup: 0 },
+ { id: 't2', title: 'Task 2', description: 'Desc 2', parallelGroup: 1 },
+ ];
+
+ scheduler.buildSchedule(items);
+ const nextGroup = scheduler.getNextReadyGroup();
+
+ expect(nextGroup).not.toBeNull();
+ expect(nextGroup!.groupNumber).toBe(0);
+ });
+
+ it('should update task status', () => {
+ const items: PlanItem[] = [
+ { id: 't1', title: 'Task 1', description: 'Desc 1', parallelGroup: 0 },
+ ];
+
+ scheduler.buildSchedule(items);
+ scheduler.startGroup(0);
+ scheduler.updateTaskStatus('t1', 'completed');
+
+ const stats = scheduler.getStats();
+ expect(stats.completedTasks).toBe(1);
+ });
+
+ it('should determine execution mode based on task characteristics', () => {
+ const items: PlanItem[] = [
+ {
+ id: 't1',
+ title: 'High token task',
+ description: 'Desc',
+ parallelGroup: 0,
+ estimatedTokens: 60000,
+ },
+ ];
+
+ const schedule = scheduler.buildSchedule(items);
+ expect(schedule.groups[0].executionMode).toBe('session');
+ });
+});
+
+describe('ExecutionBridge', () => {
+ let bridge: ExecutionBridge;
+
+ beforeEach(() => {
+ resetExecutionBridge();
+ resetGroupScheduler();
+ resetModelSelector();
+ bridge = new ExecutionBridge();
+ });
+
+ afterEach(() => {
+ bridge.reset();
+ resetExecutionBridge();
+ resetGroupScheduler();
+ resetModelSelector();
+ });
+
+ it('should have idle status initially', () => {
+ expect(bridge.status).toBe('idle');
+ });
+
+ it('should load a plan', () => {
+ const items: PlanItem[] = [
+ { id: 't1', title: 'Task 1', description: 'Desc 1', parallelGroup: 0 },
+ { id: 't2', title: 'Task 2', description: 'Desc 2', parallelGroup: 1 },
+ ];
+
+ const schedule = bridge.loadPlan(items);
+
+ expect(schedule).not.toBeNull();
+ expect(schedule.totalTasks).toBe(2);
+ expect(bridge.status).toBe('idle');
+ });
+
+ it('should report progress', () => {
+ const items: PlanItem[] = [
+ { id: 't1', title: 'Task 1', description: 'Desc 1' },
+ ];
+
+ bridge.loadPlan(items);
+ const progress = bridge.getProgress();
+
+ expect(progress.totalTasks).toBe(1);
+ expect(progress.completedTasks).toBe(0);
+ expect(progress.status).toBe('idle');
+ });
+
+ it('should update model config', () => {
+ bridge.updateModelConfig({ defaultModel: 'opus' });
+ const config = bridge.getModelConfig();
+ expect(config.defaultModel).toBe('opus');
+ });
+
+ it('should reset properly', () => {
+ const items: PlanItem[] = [
+ { id: 't1', title: 'Task 1', description: 'Desc 1' },
+ ];
+
+ bridge.loadPlan(items);
+ bridge.reset();
+
+ expect(bridge.status).toBe('idle');
+ expect(bridge.getProgress().totalTasks).toBe(0);
+ });
+
+ it('should throw when starting without a spawner', async () => {
+ const items: PlanItem[] = [
+ { id: 't1', title: 'Task 1', description: 'Desc 1' },
+ ];
+
+ bridge.loadPlan(items);
+
+ await expect(bridge.start()).rejects.toThrow('No agent spawner configured');
+ });
+
+ it('should track execution history', () => {
+ const history = bridge.getHistory();
+ expect(Array.isArray(history)).toBe(true);
+ });
+});