Files
Maestro/src/renderer/hooks/useBatchProcessor.ts
Pedram Amini ee2a61a87b refactor: extract system prompts to centralized markdown files
Move all built-in prompts from inline code to separate .md files in src/prompts/
for easier editing without code changes. Prompts use {{TEMPLATE_VARIABLES}} that
are substituted at runtime using the central substituteTemplateVariables function.

Changes:
- Add src/prompts/ directory with 7 prompt files (wizard, AutoRun, etc.)
- Add index.ts for central exports using Vite ?raw imports
- Add esbuild plugin in build-cli.mjs to support ?raw imports for CLI
- Update wizardPrompts.ts and phaseGenerator.ts to use central substitution
- Update CLAUDE.md documentation with new prompt location references
- Add TypeScript declaration for *.md?raw imports in global.d.ts

Claude ID: 38553613-f82f-4ce1-973e-fa80d42af3da
Maestro ID: b9bc0d08-5be2-4fdf-93cd-5618a8d53b35
2025-12-14 03:26:48 -06:00

1186 lines
45 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from 'react';
import type { BatchRunState, BatchRunConfig, BatchDocumentEntry, Session, HistoryEntry, UsageStats, Group, AutoRunStats } from '../types';
import { substituteTemplateVariables, TemplateContext } from '../utils/templateVariables';
import { getBadgeForTime, getNextBadge, formatTimeRemaining } from '../constants/conductorBadges';
import { autorunSynopsisPrompt } from '../../prompts';
// Regex to count unchecked markdown checkboxes: - [ ] task
const UNCHECKED_TASK_REGEX = /^[\s]*-\s*\[\s*\]\s*.+$/gm;
// Regex to match checked markdown checkboxes for reset-on-completion
// Matches both [x] and [X] with various checkbox formats (standard and GitHub-style)
const CHECKED_TASK_REGEX = /^(\s*-\s*)\[[xX✓✔]\]/gm;
// Default empty batch state
const DEFAULT_BATCH_STATE: BatchRunState = {
isRunning: false,
isStopping: false,
// Multi-document progress (new fields)
documents: [],
currentDocumentIndex: 0,
currentDocTasksTotal: 0,
currentDocTasksCompleted: 0,
totalTasksAcrossAllDocs: 0,
completedTasksAcrossAllDocs: 0,
// Loop mode
loopEnabled: false,
loopIteration: 0,
// Folder path for file operations
folderPath: '',
// Worktree tracking
worktreeActive: false,
worktreePath: undefined,
worktreeBranch: undefined,
// Legacy fields (kept for backwards compatibility)
totalTasks: 0,
completedTasks: 0,
currentTaskIndex: 0,
originalContent: '',
sessionIds: []
};
interface BatchCompleteInfo {
sessionId: string;
sessionName: string;
completedTasks: number;
totalTasks: number;
wasStopped: boolean;
elapsedTimeMs: number;
}
interface PRResultInfo {
sessionId: string;
sessionName: string;
success: boolean;
prUrl?: string;
error?: string;
}
interface UseBatchProcessorProps {
sessions: Session[];
groups: Group[];
onUpdateSession: (sessionId: string, updates: Partial<Session>) => void;
onSpawnAgent: (sessionId: string, prompt: string, cwdOverride?: string) => Promise<{ success: boolean; response?: string; claudeSessionId?: string; usageStats?: UsageStats }>;
onSpawnSynopsis: (sessionId: string, cwd: string, claudeSessionId: string, prompt: string) => Promise<{ success: boolean; response?: string }>;
onAddHistoryEntry: (entry: Omit<HistoryEntry, 'id'>) => void;
onComplete?: (info: BatchCompleteInfo) => void;
// Callback for PR creation results (success or failure)
onPRResult?: (info: PRResultInfo) => void;
// TTS settings for speaking synopsis after each task
audioFeedbackEnabled?: boolean;
audioFeedbackCommand?: string;
// Auto Run stats for achievement progress in final summary
autoRunStats?: AutoRunStats;
}
interface UseBatchProcessorReturn {
// Map of session ID to batch state
batchRunStates: Record<string, BatchRunState>;
// Get batch state for a specific session
getBatchState: (sessionId: string) => BatchRunState;
// Check if any session has an active batch
hasAnyActiveBatch: boolean;
// Get list of session IDs with active batches
activeBatchSessionIds: string[];
// Start batch run for a specific session with multi-document support
startBatchRun: (sessionId: string, config: BatchRunConfig, folderPath: string) => Promise<void>;
// Stop batch run for a specific session
stopBatchRun: (sessionId: string) => void;
// Custom prompts per session
customPrompts: Record<string, string>;
setCustomPrompt: (sessionId: string, prompt: string) => void;
}
/**
* Format duration in human-readable format for loop summaries
*/
function formatLoopDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
/**
* Create a loop summary history entry
*/
interface LoopSummaryParams {
loopIteration: number;
loopTasksCompleted: number;
loopStartTime: number;
loopTotalInputTokens: number;
loopTotalOutputTokens: number;
loopTotalCost: number;
sessionCwd: string;
sessionId: string;
isFinal: boolean;
exitReason?: string;
}
function createLoopSummaryEntry(params: LoopSummaryParams): Omit<HistoryEntry, 'id'> {
const {
loopIteration,
loopTasksCompleted,
loopStartTime,
loopTotalInputTokens,
loopTotalOutputTokens,
loopTotalCost,
sessionCwd,
sessionId,
isFinal,
exitReason
} = params;
const loopElapsedMs = Date.now() - loopStartTime;
const loopNumber = loopIteration + 1;
const summaryPrefix = isFinal ? `Loop ${loopNumber} (final)` : `Loop ${loopNumber}`;
const loopSummary = `${summaryPrefix} completed: ${loopTasksCompleted} task${loopTasksCompleted !== 1 ? 's' : ''} accomplished`;
const loopDetails = [
`**${summaryPrefix} Summary**`,
'',
`- **Tasks Accomplished:** ${loopTasksCompleted}`,
`- **Duration:** ${formatLoopDuration(loopElapsedMs)}`,
loopTotalInputTokens > 0 || loopTotalOutputTokens > 0
? `- **Tokens:** ${(loopTotalInputTokens + loopTotalOutputTokens).toLocaleString()} (${loopTotalInputTokens.toLocaleString()} in / ${loopTotalOutputTokens.toLocaleString()} out)`
: '',
loopTotalCost > 0 ? `- **Cost:** $${loopTotalCost.toFixed(4)}` : '',
exitReason ? `- **Exit Reason:** ${exitReason}` : '',
].filter(line => line !== '').join('\n');
return {
type: 'AUTO',
timestamp: Date.now(),
summary: loopSummary,
fullResponse: loopDetails,
projectPath: sessionCwd,
sessionId: sessionId,
success: true,
elapsedTimeMs: loopElapsedMs,
usageStats: loopTotalInputTokens > 0 || loopTotalOutputTokens > 0 ? {
inputTokens: loopTotalInputTokens,
outputTokens: loopTotalOutputTokens,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
totalCostUsd: loopTotalCost,
contextWindow: 0
} : undefined
};
}
/**
* Count unchecked tasks in markdown content
* Matches lines like: - [ ] task description
*/
export function countUnfinishedTasks(content: string): number {
const matches = content.match(UNCHECKED_TASK_REGEX);
return matches ? matches.length : 0;
}
/**
* Uncheck all markdown checkboxes in content (for reset-on-completion)
* Converts all - [x] to - [ ] (case insensitive)
*/
export function uncheckAllTasks(content: string): string {
return content.replace(CHECKED_TASK_REGEX, '$1[ ]');
}
/**
* Hook for managing batch processing of scratchpad tasks across multiple sessions
*/
// Synopsis prompt for batch tasks - requests a two-part response
const BATCH_SYNOPSIS_PROMPT = autorunSynopsisPrompt;
/**
* Parse a synopsis response into short summary and full synopsis
* Expected format:
* **Summary:** Short 1-2 sentence summary
* **Details:** Detailed paragraph...
*/
function parseSynopsis(response: string): { shortSummary: string; fullSynopsis: string } {
// Clean up ANSI codes and box drawing characters
const clean = response
.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/─+/g, '')
.replace(/[│┌┐└┘├┤┬┴┼]/g, '')
.trim();
// Try to extract Summary and Details sections
const summaryMatch = clean.match(/\*\*Summary:\*\*\s*(.+?)(?=\*\*Details:\*\*|$)/is);
const detailsMatch = clean.match(/\*\*Details:\*\*\s*(.+?)$/is);
const shortSummary = summaryMatch?.[1]?.trim() || clean.split('\n')[0]?.trim() || 'Task completed';
const details = detailsMatch?.[1]?.trim() || '';
// Full synopsis includes both parts
const fullSynopsis = details ? `${shortSummary}\n\n${details}` : shortSummary;
return { shortSummary, fullSynopsis };
}
export function useBatchProcessor({
sessions,
groups,
onUpdateSession,
onSpawnAgent,
onSpawnSynopsis,
onAddHistoryEntry,
onComplete,
onPRResult,
audioFeedbackEnabled,
audioFeedbackCommand,
autoRunStats
}: UseBatchProcessorProps): UseBatchProcessorReturn {
// Batch states per session
const [batchRunStates, setBatchRunStates] = useState<Record<string, BatchRunState>>({});
// Custom prompts per session
const [customPrompts, setCustomPrompts] = useState<Record<string, string>>({});
// Refs for tracking stop requests per session
const stopRequestedRefs = useRef<Record<string, boolean>>({});
// Ref to always have access to latest sessions (fixes stale closure in startBatchRun)
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
// Helper to get batch state for a session
const getBatchState = useCallback((sessionId: string): BatchRunState => {
return batchRunStates[sessionId] || DEFAULT_BATCH_STATE;
}, [batchRunStates]);
// Check if any session has an active batch
const hasAnyActiveBatch = Object.values(batchRunStates).some(state => state.isRunning);
// Get list of session IDs with active batches
const activeBatchSessionIds = Object.entries(batchRunStates)
.filter(([_, state]) => state.isRunning)
.map(([sessionId]) => sessionId);
// Set custom prompt for a session
const setCustomPrompt = useCallback((sessionId: string, prompt: string) => {
setCustomPrompts(prev => ({ ...prev, [sessionId]: prompt }));
}, []);
// Broadcast batch run state changes to web interface
useEffect(() => {
// Broadcast state for each session that has batch state
Object.entries(batchRunStates).forEach(([sessionId, state]) => {
if (state.isRunning || state.completedTasks > 0) {
window.maestro.web.broadcastAutoRunState(sessionId, {
isRunning: state.isRunning,
totalTasks: state.totalTasks,
completedTasks: state.completedTasks,
currentTaskIndex: state.currentTaskIndex,
isStopping: state.isStopping,
});
} else {
// When not running and no completed tasks, broadcast null to clear the state
window.maestro.web.broadcastAutoRunState(sessionId, null);
}
});
}, [batchRunStates]);
/**
* Helper function to read a document and count its tasks
*/
const readDocAndCountTasks = async (folderPath: string, filename: string): Promise<{ content: string; taskCount: number }> => {
const result = await window.maestro.autorun.readDoc(folderPath, filename + '.md');
if (!result.success || !result.content) {
return { content: '', taskCount: 0 };
}
return { content: result.content, taskCount: countUnfinishedTasks(result.content) };
};
/**
* Generate PR body from completed tasks
*/
const generatePRBody = (documents: BatchDocumentEntry[], totalTasksCompleted: number): string => {
const docList = documents.map(d => `- ${d.filename}`).join('\n');
return `## Auto Run Summary
**Documents processed:**
${docList}
**Total tasks completed:** ${totalTasksCompleted}
---
*This PR was automatically created by Maestro Auto Run.*`;
};
/**
* Start a batch processing run for a specific session with multi-document support
*/
const startBatchRun = useCallback(async (sessionId: string, config: BatchRunConfig, folderPath: string) => {
console.log('[BatchProcessor] startBatchRun called:', { sessionId, folderPath, config });
// Use sessionsRef to get latest sessions (handles case where session was just created)
const session = sessionsRef.current.find(s => s.id === sessionId);
if (!session) {
console.error('[BatchProcessor] Session not found for batch processing:', sessionId);
return;
}
const { documents, prompt, loopEnabled, maxLoops, worktree } = config;
console.log('[BatchProcessor] Config parsed - documents:', documents.length, 'loopEnabled:', loopEnabled, 'maxLoops:', maxLoops);
if (documents.length === 0) {
console.warn('[BatchProcessor] No documents provided for batch processing:', sessionId);
return;
}
// Debug log: show document configuration
console.log('[BatchProcessor] Starting batch with documents:', documents.map(d => ({
filename: d.filename,
resetOnCompletion: d.resetOnCompletion
})));
// Track batch start time for completion notification
const batchStartTime = Date.now();
// Reset stop flag for this session
stopRequestedRefs.current[sessionId] = false;
// Set up worktree if enabled
let effectiveCwd = session.cwd; // Default to session's cwd
let worktreeActive = false;
let worktreePath: string | undefined;
let worktreeBranch: string | undefined;
if (worktree?.enabled && worktree.path && worktree.branchName) {
console.log('[BatchProcessor] Setting up worktree at', worktree.path, 'with branch', worktree.branchName);
try {
// Set up or reuse the worktree
const setupResult = await window.maestro.git.worktreeSetup(
session.cwd,
worktree.path,
worktree.branchName
);
if (!setupResult.success) {
console.error('[BatchProcessor] Failed to set up worktree:', setupResult.error);
// Show error to user and abort
return;
}
// If worktree exists but on different branch, checkout the requested branch
if (setupResult.branchMismatch) {
console.log('[BatchProcessor] Worktree exists with different branch, checking out', worktree.branchName);
const checkoutResult = await window.maestro.git.worktreeCheckout(
worktree.path,
worktree.branchName,
true // createIfMissing
);
if (!checkoutResult.success) {
if (checkoutResult.hasUncommittedChanges) {
console.error('[BatchProcessor] Cannot checkout: worktree has uncommitted changes');
// Abort - user needs to handle uncommitted changes first
return;
} else {
console.error('[BatchProcessor] Failed to checkout branch:', checkoutResult.error);
return;
}
}
}
// Worktree is ready - use it as the working directory
effectiveCwd = worktree.path;
worktreeActive = true;
worktreePath = worktree.path;
worktreeBranch = worktree.branchName;
console.log('[BatchProcessor] Worktree ready at', effectiveCwd);
} catch (error) {
console.error('[BatchProcessor] Error setting up worktree:', error);
return;
}
}
// Get git branch for template variable substitution
let gitBranch: string | undefined;
if (session.isGitRepo) {
try {
const status = await window.maestro.git.getStatus(effectiveCwd);
gitBranch = status.branch;
} catch {
// Ignore git errors - branch will be empty string
}
}
// Find group name for this session (sessions have groupId, groups have id)
const sessionGroup = session.groupId ? groups.find(g => g.id === session.groupId) : null;
const groupName = sessionGroup?.name;
// Calculate initial total tasks across all documents
let initialTotalTasks = 0;
for (const doc of documents) {
const { taskCount } = await readDocAndCountTasks(folderPath, doc.filename);
console.log(`[BatchProcessor] Document ${doc.filename}: ${taskCount} tasks`);
initialTotalTasks += taskCount;
}
console.log(`[BatchProcessor] Initial total tasks: ${initialTotalTasks}`);
if (initialTotalTasks === 0) {
console.warn('No unchecked tasks found across all documents for session:', sessionId);
return;
}
// Initialize batch run state
setBatchRunStates(prev => ({
...prev,
[sessionId]: {
isRunning: true,
isStopping: false,
// Multi-document progress
documents: documents.map(d => d.filename),
currentDocumentIndex: 0,
currentDocTasksTotal: 0,
currentDocTasksCompleted: 0,
totalTasksAcrossAllDocs: initialTotalTasks,
completedTasksAcrossAllDocs: 0,
// Loop mode
loopEnabled,
loopIteration: 0,
maxLoops,
// Folder path for file operations
folderPath,
// Worktree tracking
worktreeActive,
worktreePath,
worktreeBranch,
// Legacy fields (for backwards compatibility)
totalTasks: initialTotalTasks,
completedTasks: 0,
currentTaskIndex: 0,
originalContent: '',
customPrompt: prompt !== '' ? prompt : undefined,
sessionIds: [],
startTime: batchStartTime
}
}));
// AUTORUN LOG: Start
try {
console.log('[AUTORUN] Logging start event - calling window.maestro.logger.autorun');
window.maestro.logger.autorun(
`Auto Run started`,
session.name,
{
documents: documents.map(d => d.filename),
totalTasks: initialTotalTasks,
loopEnabled,
maxLoops: maxLoops ?? 'unlimited'
}
);
console.log('[AUTORUN] Start event logged successfully');
} catch (err) {
console.error('[AUTORUN] Error logging start event:', err);
}
// Store custom prompt for persistence
setCustomPrompts(prev => ({ ...prev, [sessionId]: prompt }));
// Collect Claude session IDs and track completion
const claudeSessionIds: string[] = [];
let totalCompletedTasks = 0;
let loopIteration = 0;
// Per-loop tracking for loop summary
let loopStartTime = Date.now();
let loopTasksCompleted = 0;
let loopTasksDiscovered = 0;
let loopTotalInputTokens = 0;
let loopTotalOutputTokens = 0;
let loopTotalCost = 0;
// Cumulative tracking for final Auto Run summary (across all loops)
let totalInputTokens = 0;
let totalOutputTokens = 0;
let totalCost = 0;
// Track consecutive runs where document content didn't change to detect stalling
// If the document hash is identical before/after a run (and no tasks checked), the LLM is stuck
let consecutiveNoChangeCount = 0;
const MAX_CONSECUTIVE_NO_CHANGES = 2; // Exit after 2 consecutive runs with no document changes
let stalledDueToNoProgress = false;
// Helper to add final loop summary (defined here so it has access to tracking vars)
const addFinalLoopSummary = (exitReason: string) => {
// AUTORUN LOG: Exit
window.maestro.logger.autorun(
`Auto Run exiting: ${exitReason}`,
session.name,
{
reason: exitReason,
totalTasksCompleted: totalCompletedTasks,
loopsCompleted: loopIteration + 1
}
);
if (loopEnabled && (loopTasksCompleted > 0 || loopIteration > 0)) {
onAddHistoryEntry(createLoopSummaryEntry({
loopIteration,
loopTasksCompleted,
loopStartTime,
loopTotalInputTokens,
loopTotalOutputTokens,
loopTotalCost,
sessionCwd: session.cwd,
sessionId,
isFinal: true,
exitReason
}));
}
};
// Main processing loop (handles loop mode)
while (true) {
// Check for stop request
if (stopRequestedRefs.current[sessionId]) {
console.log('[BatchProcessor] Batch run stopped by user for session:', sessionId);
addFinalLoopSummary('Stopped by user');
break;
}
// Track if any tasks were processed in this iteration
let anyTasksProcessedThisIteration = false;
// Track tasks completed in non-reset documents this iteration
// This is critical for loop mode: if only reset docs have tasks, we'd loop forever
let tasksCompletedInNonResetDocs = 0;
// Process each document in order
for (let docIndex = 0; docIndex < documents.length; docIndex++) {
// Check for stop request before each document
if (stopRequestedRefs.current[sessionId]) {
console.log('[BatchProcessor] Batch run stopped by user at document', docIndex, 'for session:', sessionId);
break;
}
const docEntry = documents[docIndex];
const docFilePath = `${folderPath}/${docEntry.filename}.md`;
// Read document and count tasks
let { taskCount: remainingTasks, content: docContent } = await readDocAndCountTasks(folderPath, docEntry.filename);
// Handle documents with no unchecked tasks
if (remainingTasks === 0) {
// For reset-on-completion documents, check if there are checked tasks that need resetting
if (docEntry.resetOnCompletion && loopEnabled) {
const checkedTaskCount = (docContent.match(CHECKED_TASK_REGEX) || []).length;
if (checkedTaskCount > 0) {
console.log(`[BatchProcessor] Document ${docEntry.filename} has ${checkedTaskCount} checked tasks - resetting for next iteration`);
const resetContent = uncheckAllTasks(docContent);
await window.maestro.autorun.writeDoc(folderPath, docEntry.filename + '.md', resetContent);
// Update task count in state
const resetTaskCount = countUnfinishedTasks(resetContent);
setBatchRunStates(prev => ({
...prev,
[sessionId]: {
...prev[sessionId],
totalTasksAcrossAllDocs: prev[sessionId].totalTasksAcrossAllDocs + resetTaskCount,
totalTasks: prev[sessionId].totalTasks + resetTaskCount
}
}));
}
}
console.log(`[BatchProcessor] Skipping document ${docEntry.filename} - no unchecked tasks`);
continue;
}
console.log(`[BatchProcessor] Processing document ${docEntry.filename} with ${remainingTasks} tasks`);
// AUTORUN LOG: Document processing
window.maestro.logger.autorun(
`Processing document: ${docEntry.filename}`,
session.name,
{
document: docEntry.filename,
tasksRemaining: remainingTasks,
loopNumber: loopIteration + 1
}
);
// Update state to show current document
setBatchRunStates(prev => ({
...prev,
[sessionId]: {
...prev[sessionId],
currentDocumentIndex: docIndex,
currentDocTasksTotal: remainingTasks,
currentDocTasksCompleted: 0
}
}));
let docTasksCompleted = 0;
// Process tasks in this document until none remain
while (remainingTasks > 0) {
// Check for stop request before each task
if (stopRequestedRefs.current[sessionId]) {
console.log('[BatchProcessor] Batch run stopped by user during document', docEntry.filename);
break;
}
// Build template context for this task
const templateContext: TemplateContext = {
session,
gitBranch,
groupName,
autoRunFolder: folderPath,
loopNumber: loopIteration + 1, // 1-indexed
documentName: docEntry.filename,
documentPath: docFilePath,
};
// Substitute template variables in the prompt
const finalPrompt = substituteTemplateVariables(prompt, templateContext);
// Read document content and expand template variables in it
const docReadResult = await window.maestro.autorun.readDoc(folderPath, docEntry.filename + '.md');
// Capture content before task run for stall detection
const contentBeforeTask = docReadResult.content || '';
if (docReadResult.success && docReadResult.content) {
const expandedDocContent = substituteTemplateVariables(docReadResult.content, templateContext);
// Write the expanded content back to the document temporarily
// (Claude will read this file, so it needs the expanded variables)
if (expandedDocContent !== docReadResult.content) {
await window.maestro.autorun.writeDoc(folderPath, docEntry.filename + '.md', expandedDocContent);
}
}
try {
// Capture start time for elapsed time tracking
const taskStartTime = Date.now();
// Spawn agent with the prompt, using worktree path if active
const result = await onSpawnAgent(sessionId, finalPrompt, worktreeActive ? effectiveCwd : undefined);
// Capture elapsed time
const elapsedTimeMs = Date.now() - taskStartTime;
if (result.claudeSessionId) {
claudeSessionIds.push(result.claudeSessionId);
// Register as auto-initiated Maestro session
window.maestro.claude.registerSessionOrigin(session.cwd, result.claudeSessionId, 'auto')
.catch(err => console.error('[BatchProcessor] Failed to register session origin:', err));
}
anyTasksProcessedThisIteration = true;
// Re-read document to get updated task count and content
const { taskCount: newRemainingTasks, content: contentAfterTask } = await readDocAndCountTasks(folderPath, docEntry.filename);
// Calculate tasks completed - ensure it's never negative (Claude may have added tasks)
const tasksCompletedThisRun = Math.max(0, remainingTasks - newRemainingTasks);
// Detect stalling: if document content is unchanged and no tasks were checked off
const documentUnchanged = contentBeforeTask === contentAfterTask;
if (documentUnchanged && tasksCompletedThisRun === 0) {
consecutiveNoChangeCount++;
console.log(`[BatchProcessor] Document unchanged, no tasks completed (${consecutiveNoChangeCount}/${MAX_CONSECUTIVE_NO_CHANGES} consecutive)`);
} else {
// Reset counter on any document change or task completion
consecutiveNoChangeCount = 0;
}
// Update counters
docTasksCompleted += tasksCompletedThisRun;
totalCompletedTasks += tasksCompletedThisRun;
loopTasksCompleted += tasksCompletedThisRun;
// Track token usage for loop summary and cumulative totals
if (result.usageStats) {
loopTotalInputTokens += result.usageStats.inputTokens || 0;
loopTotalOutputTokens += result.usageStats.outputTokens || 0;
loopTotalCost += result.usageStats.totalCostUsd || 0;
// Also track cumulative totals for final summary
totalInputTokens += result.usageStats.inputTokens || 0;
totalOutputTokens += result.usageStats.outputTokens || 0;
totalCost += result.usageStats.totalCostUsd || 0;
}
// Track non-reset document completions for loop exit logic
if (!docEntry.resetOnCompletion) {
tasksCompletedInNonResetDocs += tasksCompletedThisRun;
}
// Update progress state
setBatchRunStates(prev => ({
...prev,
[sessionId]: {
...prev[sessionId],
currentDocTasksCompleted: docTasksCompleted,
completedTasksAcrossAllDocs: totalCompletedTasks,
// Legacy fields
completedTasks: totalCompletedTasks,
currentTaskIndex: totalCompletedTasks,
sessionIds: [...(prev[sessionId]?.sessionIds || []), result.claudeSessionId || '']
}
}));
// Generate synopsis for successful tasks with a Claude session
let shortSummary = `[${docEntry.filename}] Task completed`;
let fullSynopsis = shortSummary;
if (result.success && result.claudeSessionId) {
// Request a synopsis from the agent by resuming the session
try {
const synopsisResult = await onSpawnSynopsis(
sessionId,
session.cwd,
result.claudeSessionId,
BATCH_SYNOPSIS_PROMPT
);
if (synopsisResult.success && synopsisResult.response) {
const parsed = parseSynopsis(synopsisResult.response);
shortSummary = parsed.shortSummary;
fullSynopsis = parsed.fullSynopsis;
}
} catch (err) {
console.error('[BatchProcessor] Synopsis generation failed:', err);
}
} else if (!result.success) {
shortSummary = `[${docEntry.filename}] Task failed`;
fullSynopsis = shortSummary;
}
// Add history entry
onAddHistoryEntry({
type: 'AUTO',
timestamp: Date.now(),
summary: shortSummary,
fullResponse: fullSynopsis,
claudeSessionId: result.claudeSessionId,
projectPath: session.cwd,
sessionId: sessionId,
success: result.success,
usageStats: result.usageStats,
elapsedTimeMs
});
// Speak the synopsis via TTS if audio feedback is enabled
if (audioFeedbackEnabled && audioFeedbackCommand && shortSummary) {
window.maestro.notification.speak(shortSummary, audioFeedbackCommand).catch(err => {
console.error('[BatchProcessor] Failed to speak synopsis:', err);
});
}
// Check if we've hit the stalling threshold
if (consecutiveNoChangeCount >= MAX_CONSECUTIVE_NO_CHANGES) {
console.warn(`[BatchProcessor] Detected stalling: ${consecutiveNoChangeCount} consecutive runs with no document changes`);
stalledDueToNoProgress = true;
addFinalLoopSummary(`Document has unchecked tasks but appears complete (${consecutiveNoChangeCount} consecutive runs with no changes)`);
break;
}
remainingTasks = newRemainingTasks;
console.log(`[BatchProcessor] Document ${docEntry.filename}: ${remainingTasks} tasks remaining`);
} catch (error) {
console.error(`[BatchProcessor] Error running task in ${docEntry.filename} for session ${sessionId}:`, error);
// Continue to next task on error
remainingTasks--;
}
}
// Check for stop or stalling before doing reset
if (stopRequestedRefs.current[sessionId] || stalledDueToNoProgress) {
break;
}
// Document complete - handle reset-on-completion if enabled
console.log(`[BatchProcessor] Document ${docEntry.filename} complete. resetOnCompletion=${docEntry.resetOnCompletion}, docTasksCompleted=${docTasksCompleted}`);
if (docEntry.resetOnCompletion && docTasksCompleted > 0) {
console.log(`[BatchProcessor] Resetting document ${docEntry.filename} (reset-on-completion enabled)`);
// AUTORUN LOG: Document reset
window.maestro.logger.autorun(
`Resetting document: ${docEntry.filename}`,
session.name,
{
document: docEntry.filename,
tasksCompleted: docTasksCompleted,
loopNumber: loopIteration + 1
}
);
// Read the current content and uncheck all tasks
const { content: currentContent } = await readDocAndCountTasks(folderPath, docEntry.filename);
// Count checked tasks before reset
const checkedMatches = currentContent.match(CHECKED_TASK_REGEX) || [];
const checkedBefore = checkedMatches.length;
console.log(`[BatchProcessor] Document ${docEntry.filename} has ${checkedBefore} checked tasks before reset`);
console.log(`[BatchProcessor] Checked task matches:`, checkedMatches);
const resetContent = uncheckAllTasks(currentContent);
// Count unchecked tasks after reset
const uncheckedAfter = countUnfinishedTasks(resetContent);
console.log(`[BatchProcessor] Document ${docEntry.filename} has ${uncheckedAfter} unchecked tasks after reset`);
// Log first 500 chars of content before/after for debugging
console.log(`[BatchProcessor] Content before reset (first 500):`, currentContent.substring(0, 500));
console.log(`[BatchProcessor] Content after reset (first 500):`, resetContent.substring(0, 500));
// Write the reset content back
const writeResult = await window.maestro.autorun.writeDoc(folderPath, docEntry.filename + '.md', resetContent);
console.log(`[BatchProcessor] Write result for ${docEntry.filename}:`, writeResult);
// If loop is enabled, add the reset tasks back to the total
if (loopEnabled) {
const resetTaskCount = countUnfinishedTasks(resetContent);
setBatchRunStates(prev => ({
...prev,
[sessionId]: {
...prev[sessionId],
totalTasksAcrossAllDocs: prev[sessionId].totalTasksAcrossAllDocs + resetTaskCount,
totalTasks: prev[sessionId].totalTasks + resetTaskCount
}
}));
}
}
}
// Check if we stalled due to no progress
if (stalledDueToNoProgress) {
break;
}
// Check if we should continue looping
if (!loopEnabled) {
// No loop mode - we're done after one pass
// AUTORUN LOG: Exit (non-loop mode)
window.maestro.logger.autorun(
`Auto Run completed (single pass)`,
session.name,
{
reason: 'Single pass completed',
totalTasksCompleted: totalCompletedTasks,
loopsCompleted: 1
}
);
break;
}
// Check if we've hit the max loop limit
if (maxLoops !== null && maxLoops !== undefined && loopIteration + 1 >= maxLoops) {
console.log(`[BatchProcessor] Reached max loop limit (${maxLoops}), exiting loop`);
addFinalLoopSummary(`Reached max loop limit (${maxLoops})`);
break;
}
// Check for stop request after full pass
if (stopRequestedRefs.current[sessionId]) {
addFinalLoopSummary('Stopped by user');
break;
}
// Safety check: if we didn't process ANY tasks this iteration, exit to avoid infinite loop
if (!anyTasksProcessedThisIteration) {
console.warn('[BatchProcessor] No tasks processed this iteration - exiting to avoid infinite loop');
addFinalLoopSummary('No tasks processed this iteration');
break;
}
// Loop mode: check if we should continue looping
// Check if there are any non-reset documents in the playbook
const hasAnyNonResetDocs = documents.some(doc => !doc.resetOnCompletion);
if (hasAnyNonResetDocs) {
// If we have non-reset docs, only continue if they have remaining tasks
let anyNonResetDocsHaveTasks = false;
for (const doc of documents) {
if (doc.resetOnCompletion) continue;
const { taskCount } = await readDocAndCountTasks(folderPath, doc.filename);
if (taskCount > 0) {
anyNonResetDocsHaveTasks = true;
break;
}
}
if (!anyNonResetDocsHaveTasks) {
console.log('[BatchProcessor] All non-reset documents completed, exiting loop');
addFinalLoopSummary('All tasks completed');
break;
}
}
// If all documents are reset docs, we continue looping (maxLoops check above will stop us)
// Re-scan all documents to get fresh task counts for next loop (tasks may have been added/removed)
let newTotalTasks = 0;
for (const doc of documents) {
const { taskCount } = await readDocAndCountTasks(folderPath, doc.filename);
newTotalTasks += taskCount;
}
// Calculate loop elapsed time
const loopElapsedMs = Date.now() - loopStartTime;
// Add loop summary history entry
const loopSummary = `Loop ${loopIteration + 1} completed: ${loopTasksCompleted} task${loopTasksCompleted !== 1 ? 's' : ''} accomplished`;
const loopDetails = [
`**Loop ${loopIteration + 1} Summary**`,
'',
`- **Tasks Accomplished:** ${loopTasksCompleted}`,
`- **Duration:** ${formatLoopDuration(loopElapsedMs)}`,
loopTotalInputTokens > 0 || loopTotalOutputTokens > 0
? `- **Tokens:** ${(loopTotalInputTokens + loopTotalOutputTokens).toLocaleString()} (${loopTotalInputTokens.toLocaleString()} in / ${loopTotalOutputTokens.toLocaleString()} out)`
: '',
loopTotalCost > 0 ? `- **Cost:** $${loopTotalCost.toFixed(4)}` : '',
`- **Tasks Discovered for Next Loop:** ${newTotalTasks}`,
].filter(line => line !== '').join('\n');
onAddHistoryEntry({
type: 'AUTO',
timestamp: Date.now(),
summary: loopSummary,
fullResponse: loopDetails,
projectPath: session.cwd,
sessionId: sessionId,
success: true,
elapsedTimeMs: loopElapsedMs,
usageStats: loopTotalInputTokens > 0 || loopTotalOutputTokens > 0 ? {
inputTokens: loopTotalInputTokens,
outputTokens: loopTotalOutputTokens,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
totalCostUsd: loopTotalCost,
contextWindow: 0
} : undefined
});
// Reset per-loop tracking for next iteration
loopStartTime = Date.now();
loopTasksCompleted = 0;
loopTasksDiscovered = newTotalTasks;
loopTotalInputTokens = 0;
loopTotalOutputTokens = 0;
loopTotalCost = 0;
// AUTORUN LOG: Loop completion
window.maestro.logger.autorun(
`Loop ${loopIteration + 1} completed`,
session.name,
{
loopNumber: loopIteration + 1,
tasksCompleted: loopTasksCompleted,
tasksForNextLoop: newTotalTasks
}
);
// Continue looping
loopIteration++;
console.log(`[BatchProcessor] Starting loop iteration ${loopIteration + 1}: ${newTotalTasks} tasks across all documents`);
setBatchRunStates(prev => ({
...prev,
[sessionId]: {
...prev[sessionId],
loopIteration,
totalTasksAcrossAllDocs: newTotalTasks + prev[sessionId].completedTasksAcrossAllDocs,
totalTasks: newTotalTasks + prev[sessionId].completedTasks
}
}));
}
// Create PR if worktree was used, PR creation is enabled, and not stopped
const wasStopped = stopRequestedRefs.current[sessionId] || false;
const sessionName = session.name || session.cwd.split('/').pop() || 'Unknown';
if (worktreeActive && worktree?.createPROnCompletion && !wasStopped && totalCompletedTasks > 0) {
console.log('[BatchProcessor] Creating PR from worktree branch', worktreeBranch);
try {
// Use the user-selected target branch, or fall back to default branch detection
let baseBranch = worktree.prTargetBranch;
if (!baseBranch) {
const defaultBranchResult = await window.maestro.git.getDefaultBranch(session.cwd);
baseBranch = defaultBranchResult.success && defaultBranchResult.branch
? defaultBranchResult.branch
: 'main';
}
// Generate PR title and body
const prTitle = `Auto Run: ${documents.length} document(s) processed`;
const prBody = generatePRBody(documents, totalCompletedTasks);
// Create the PR (pass ghPath if configured)
const prResult = await window.maestro.git.createPR(
effectiveCwd,
baseBranch,
prTitle,
prBody,
worktree.ghPath
);
if (prResult.success) {
console.log('[BatchProcessor] PR created successfully:', prResult.prUrl);
// Notify caller of successful PR creation
if (onPRResult) {
onPRResult({
sessionId,
sessionName,
success: true,
prUrl: prResult.prUrl
});
}
} else {
console.warn('[BatchProcessor] PR creation failed:', prResult.error);
// Notify caller of PR creation failure (doesn't fail the run)
if (onPRResult) {
onPRResult({
sessionId,
sessionName,
success: false,
error: prResult.error
});
}
}
} catch (error) {
console.error('[BatchProcessor] Error creating PR:', error);
// Notify caller of PR creation error (doesn't fail the run)
if (onPRResult) {
onPRResult({
sessionId,
sessionName,
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
}
// Add final Auto Run summary entry (no sessionId - this is a standalone synopsis)
const totalElapsedMs = Date.now() - batchStartTime;
const loopsCompleted = loopEnabled ? loopIteration + 1 : 1;
const statusText = stalledDueToNoProgress ? 'stalled' : wasStopped ? 'stopped' : 'completed';
// Calculate achievement progress for the summary
// Note: We use the stats BEFORE this run is recorded (the parent will call recordAutoRunComplete after)
// So we need to add totalElapsedMs to get the projected cumulative time
const projectedCumulativeTime = (autoRunStats?.cumulativeTimeMs || 0) + totalElapsedMs;
const currentBadge = getBadgeForTime(projectedCumulativeTime);
const nextBadge = getNextBadge(currentBadge);
const levelProgressText = nextBadge
? `Level ${currentBadge?.level || 0}${nextBadge.level}: ${formatTimeRemaining(projectedCumulativeTime, nextBadge)}`
: currentBadge
? `Level ${currentBadge.level} (${currentBadge.name}) - Maximum level achieved!`
: 'Level 0 → 1: ' + formatTimeRemaining(0, getBadgeForTime(0) || undefined);
const finalSummary = `Auto Run ${statusText}: ${totalCompletedTasks} task${totalCompletedTasks !== 1 ? 's' : ''} in ${formatLoopDuration(totalElapsedMs)}`;
const finalDetails = [
`**Auto Run Summary**`,
'',
`- **Status:** ${stalledDueToNoProgress ? 'Stalled (no progress detected)' : wasStopped ? 'Stopped by user' : 'Completed'}`,
`- **Tasks Completed:** ${totalCompletedTasks}`,
`- **Total Duration:** ${formatLoopDuration(totalElapsedMs)}`,
loopEnabled ? `- **Loops Completed:** ${loopsCompleted}` : '',
totalInputTokens > 0 || totalOutputTokens > 0
? `- **Total Tokens:** ${(totalInputTokens + totalOutputTokens).toLocaleString()} (${totalInputTokens.toLocaleString()} in / ${totalOutputTokens.toLocaleString()} out)`
: '',
totalCost > 0 ? `- **Total Cost:** $${totalCost.toFixed(4)}` : '',
'',
`- **Documents:** ${documents.map(d => d.filename).join(', ')}`,
'',
`**Achievement Progress**`,
`- ${levelProgressText}`,
].filter(line => line !== '').join('\n');
// This entry has no sessionId - it's a standalone Auto Run synopsis
onAddHistoryEntry({
type: 'AUTO',
timestamp: Date.now(),
summary: finalSummary,
fullResponse: finalDetails,
projectPath: session.cwd,
// No sessionId - this is a standalone synopsis entry
success: !wasStopped,
elapsedTimeMs: totalElapsedMs,
usageStats: totalInputTokens > 0 || totalOutputTokens > 0 ? {
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
totalCostUsd: totalCost,
contextWindow: 0
} : undefined,
achievementAction: 'openAbout' // Enable clickable link to achievements panel
});
// Reset state for this session (clear worktree tracking)
setBatchRunStates(prev => ({
...prev,
[sessionId]: {
isRunning: false,
isStopping: false,
documents: [],
currentDocumentIndex: 0,
currentDocTasksTotal: 0,
currentDocTasksCompleted: 0,
totalTasksAcrossAllDocs: 0,
completedTasksAcrossAllDocs: 0,
loopEnabled: false,
loopIteration: 0,
folderPath: '',
// Clear worktree tracking
worktreeActive: false,
worktreePath: undefined,
worktreeBranch: undefined,
totalTasks: 0,
completedTasks: 0,
currentTaskIndex: 0,
originalContent: '',
sessionIds: claudeSessionIds
}
}));
// Call completion callback if provided
if (onComplete) {
onComplete({
sessionId,
sessionName: session.name || session.cwd.split('/').pop() || 'Unknown',
completedTasks: totalCompletedTasks,
totalTasks: initialTotalTasks,
wasStopped,
elapsedTimeMs: Date.now() - batchStartTime
});
}
}, [onUpdateSession, onSpawnAgent, onSpawnSynopsis, onAddHistoryEntry, onComplete, onPRResult, audioFeedbackEnabled, audioFeedbackCommand]);
/**
* Request to stop the batch run for a specific session after current task completes
*/
const stopBatchRun = useCallback((sessionId: string) => {
stopRequestedRefs.current[sessionId] = true;
setBatchRunStates(prev => ({
...prev,
[sessionId]: {
...prev[sessionId],
isStopping: true
}
}));
}, []);
return {
batchRunStates,
getBatchState,
hasAnyActiveBatch,
activeBatchSessionIds,
startBatchRun,
stopBatchRun,
customPrompts,
setCustomPrompt
};
}