From efedc85cb1110a75748f3df0e530f3c9e27d2155 Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Sun, 25 Jan 2026 11:50:07 +0100 Subject: [PATCH] feat(loop): add streaming output mode with --stream flag (#1605) --- .changeset/cuddly-wings-drop.md | 9 + apps/cli/src/commands/loop.command.spec.ts | 3 + apps/cli/src/commands/loop.command.ts | 64 ++- packages/tm-core/src/index.ts | 3 +- packages/tm-core/src/modules/loop/index.ts | 3 +- .../tm-core/src/modules/loop/loop-domain.ts | 6 +- .../__snapshots__/presets.spec.ts.snap | 10 +- .../src/modules/loop/presets/default.ts | 4 +- .../src/modules/loop/presets/duplication.ts | 4 +- .../src/modules/loop/presets/entropy.ts | 2 +- .../src/modules/loop/presets/linting.ts | 4 +- .../src/modules/loop/presets/test-coverage.ts | 4 +- .../loop/services/loop.service.spec.ts | 7 +- .../src/modules/loop/services/loop.service.ts | 425 +++++++++++++++--- packages/tm-core/src/modules/loop/types.ts | 71 +++ tests/setup.js | 13 + 16 files changed, 554 insertions(+), 78 deletions(-) create mode 100644 .changeset/cuddly-wings-drop.md diff --git a/.changeset/cuddly-wings-drop.md b/.changeset/cuddly-wings-drop.md new file mode 100644 index 00000000..d14f2a61 --- /dev/null +++ b/.changeset/cuddly-wings-drop.md @@ -0,0 +1,9 @@ +--- +"task-master-ai": minor +--- + +Add verbose output mode to loop command with `--verbose` flag + +- New `-v, --verbose` flag shows Claude's work in real-time (thinking, tool calls) rather than waiting until the iteration completes +- New `--no-output` flag excludes full Claude output from iteration results to save memory +- Improved error handling with proper validation for incompatible options (verbose + sandbox) diff --git a/apps/cli/src/commands/loop.command.spec.ts b/apps/cli/src/commands/loop.command.spec.ts index 45702506..4cd9817a 100644 --- a/apps/cli/src/commands/loop.command.spec.ts +++ b/apps/cli/src/commands/loop.command.spec.ts @@ -91,6 +91,9 @@ describe('LoopCommand', () => { getStorageType: vi.fn().mockReturnValue('local'), getNext: vi.fn().mockResolvedValue({ id: '1', title: 'Test Task' }), getCount: vi.fn().mockResolvedValue(0) + }, + auth: { + getContext: vi.fn().mockReturnValue(null) } }; diff --git a/apps/cli/src/commands/loop.command.ts b/apps/cli/src/commands/loop.command.ts index 95c2931e..c419c1c9 100644 --- a/apps/cli/src/commands/loop.command.ts +++ b/apps/cli/src/commands/loop.command.ts @@ -5,6 +5,8 @@ import path from 'node:path'; import { type LoopConfig, + type LoopIteration, + type LoopOutputCallbacks, type LoopResult, PRESET_NAMES, type TmCore, @@ -23,6 +25,8 @@ export interface LoopCommandOptions { tag?: string; project?: string; sandbox?: boolean; + output?: boolean; + verbose?: boolean; } export class LoopCommand extends Command { @@ -49,6 +53,11 @@ export class LoopCommand extends Command { 'Project root directory (auto-detected if not provided)' ) .option('--sandbox', 'Run Claude in Docker sandbox mode') + .option( + '--no-output', + 'Exclude full Claude output from iteration results' + ) + .option('-v, --verbose', "Show Claude's work in real-time") .action((options: LoopCommandOptions) => this.execute(options)); } @@ -109,12 +118,21 @@ export class LoopCommand extends Command { } console.log(); + // Auto-detect brief name from auth context (if available) + const briefName = this.tmCore.auth.getContext()?.briefName; + const config: Partial = { iterations, prompt, progressFile, tag: options.tag, - sandbox: options.sandbox + sandbox: options.sandbox, + // CLI defaults to including output (users typically want to see it) + // Domain defaults to false (library consumers opt-in explicitly) + includeOutput: options.output ?? true, + verbose: options.verbose ?? false, + brief: briefName, + callbacks: this.createOutputCallbacks() }; const result = await this.tmCore.loop.run(config); @@ -161,6 +179,47 @@ export class LoopCommand extends Command { } } + private createOutputCallbacks(): LoopOutputCallbacks { + return { + onIterationStart: (iteration: number, total: number) => { + console.log(); + console.log(chalk.cyan(`━━━ Iteration ${iteration} of ${total} ━━━`)); + }, + onText: (text: string) => { + console.log(text); + }, + onToolUse: (toolName: string) => { + console.log(chalk.dim(` → ${toolName}`)); + }, + onError: (message: string, severity?: 'warning' | 'error') => { + if (severity === 'warning') { + console.error(chalk.yellow(`[Loop Warning] ${message}`)); + } else { + console.error(chalk.red(`[Loop Error] ${message}`)); + } + }, + onStderr: (iteration: number, text: string) => { + process.stderr.write(chalk.dim(`[Iteration ${iteration}] `) + text); + }, + onOutput: (output: string) => { + console.log(output); + }, + onIterationEnd: (iteration: LoopIteration) => { + const statusColor = + iteration.status === 'success' + ? chalk.green + : iteration.status === 'error' + ? chalk.red + : chalk.yellow; + console.log( + statusColor( + ` Iteration ${iteration.iteration} completed: ${iteration.status}` + ) + ); + } + }; + } + private displayResult(result: LoopResult): void { console.log(); console.log(chalk.bold('Loop Complete')); @@ -168,6 +227,9 @@ export class LoopCommand extends Command { console.log(`Total iterations: ${result.totalIterations}`); console.log(`Tasks completed: ${result.tasksCompleted}`); console.log(`Final status: ${this.formatStatus(result.finalStatus)}`); + if (result.errorMessage) { + console.log(chalk.red(`Error: ${result.errorMessage}`)); + } } private formatStatus(status: LoopResult['finalStatus']): string { diff --git a/packages/tm-core/src/index.ts b/packages/tm-core/src/index.ts index 4314a491..35df919d 100644 --- a/packages/tm-core/src/index.ts +++ b/packages/tm-core/src/index.ts @@ -155,7 +155,8 @@ export type { LoopPreset, LoopConfig, LoopIteration, - LoopResult + LoopResult, + LoopOutputCallbacks } from './modules/loop/index.js'; export { LoopDomain, PRESET_NAMES } from './modules/loop/index.js'; diff --git a/packages/tm-core/src/modules/loop/index.ts b/packages/tm-core/src/modules/loop/index.ts index ab3a8e16..de74015b 100644 --- a/packages/tm-core/src/modules/loop/index.ts +++ b/packages/tm-core/src/modules/loop/index.ts @@ -15,7 +15,8 @@ export type { LoopPreset, LoopConfig, LoopIteration, - LoopResult + LoopResult, + LoopOutputCallbacks } from './types.js'; // Presets - content and helpers diff --git a/packages/tm-core/src/modules/loop/loop-domain.ts b/packages/tm-core/src/modules/loop/loop-domain.ts index 342fa011..8fdbfd02 100644 --- a/packages/tm-core/src/modules/loop/loop-domain.ts +++ b/packages/tm-core/src/modules/loop/loop-domain.ts @@ -190,7 +190,11 @@ export class LoopDomain { path.join(this.projectRoot, '.taskmaster', 'progress.txt'), sleepSeconds: partial.sleepSeconds ?? 5, tag: partial.tag, - sandbox: partial.sandbox ?? false + sandbox: partial.sandbox ?? false, + includeOutput: partial.includeOutput ?? false, + verbose: partial.verbose ?? false, + brief: partial.brief, + callbacks: partial.callbacks }; } } diff --git a/packages/tm-core/src/modules/loop/presets/__snapshots__/presets.spec.ts.snap b/packages/tm-core/src/modules/loop/presets/__snapshots__/presets.spec.ts.snap index 7a0e6195..93d201fb 100644 --- a/packages/tm-core/src/modules/loop/presets/__snapshots__/presets.spec.ts.snap +++ b/packages/tm-core/src/modules/loop/presets/__snapshots__/presets.spec.ts.snap @@ -3,7 +3,7 @@ exports[`Preset Snapshots > default preset matches snapshot 1`] = ` "SETUP: If task-master command not found, run: npm i -g task-master-ai -TASK: Implement ONE task/subtask from the Task Master backlog. +TASK: Implement ONE task/subtask from the Taskmaster backlog. PROCESS: 1. Run task-master next (or use MCP) to get the next available task/subtask. @@ -26,7 +26,7 @@ IMPORTANT: `; exports[`Preset Snapshots > duplication preset matches snapshot 1`] = ` -"# Task Master Loop - Duplication +"# Taskmaster Loop - Duplication Find duplicated code and refactor into shared utilities. ONE refactor per session. @@ -60,7 +60,7 @@ Find duplicated code and refactor into shared utilities. ONE refactor per sessio `; exports[`Preset Snapshots > entropy preset matches snapshot 1`] = ` -"# Task Master Loop - Entropy (Code Smells) +"# Taskmaster Loop - Entropy (Code Smells) Find code smells and clean them up. ONE cleanup per session. @@ -102,7 +102,7 @@ Find code smells and clean them up. ONE cleanup per session. `; exports[`Preset Snapshots > linting preset matches snapshot 1`] = ` -"# Task Master Loop - Linting +"# Taskmaster Loop - Linting Fix lint errors and type errors one by one. ONE fix per session. @@ -136,7 +136,7 @@ Fix lint errors and type errors one by one. ONE fix per session. `; exports[`Preset Snapshots > test-coverage preset matches snapshot 1`] = ` -"# Task Master Loop - Test Coverage +"# Taskmaster Loop - Test Coverage Find uncovered code and write meaningful tests. ONE test per session. diff --git a/packages/tm-core/src/modules/loop/presets/default.ts b/packages/tm-core/src/modules/loop/presets/default.ts index 6660600e..f9588eb6 100644 --- a/packages/tm-core/src/modules/loop/presets/default.ts +++ b/packages/tm-core/src/modules/loop/presets/default.ts @@ -1,10 +1,10 @@ /** - * Default preset for Task Master loop - general task completion + * Default preset for Taskmaster loop - general task completion * Matches the structure of scripts/loop.sh prompt */ export const DEFAULT_PRESET = `SETUP: If task-master command not found, run: npm i -g task-master-ai -TASK: Implement ONE task/subtask from the Task Master backlog. +TASK: Implement ONE task/subtask from the Taskmaster backlog. PROCESS: 1. Run task-master next (or use MCP) to get the next available task/subtask. diff --git a/packages/tm-core/src/modules/loop/presets/duplication.ts b/packages/tm-core/src/modules/loop/presets/duplication.ts index b523b5ed..f43e12e2 100644 --- a/packages/tm-core/src/modules/loop/presets/duplication.ts +++ b/packages/tm-core/src/modules/loop/presets/duplication.ts @@ -1,7 +1,7 @@ /** - * Duplication preset for Task Master loop - code deduplication + * Duplication preset for Taskmaster loop - code deduplication */ -export const DUPLICATION_PRESET = `# Task Master Loop - Duplication +export const DUPLICATION_PRESET = `# Taskmaster Loop - Duplication Find duplicated code and refactor into shared utilities. ONE refactor per session. diff --git a/packages/tm-core/src/modules/loop/presets/entropy.ts b/packages/tm-core/src/modules/loop/presets/entropy.ts index d7b6cd25..4e25114f 100644 --- a/packages/tm-core/src/modules/loop/presets/entropy.ts +++ b/packages/tm-core/src/modules/loop/presets/entropy.ts @@ -2,7 +2,7 @@ * @fileoverview Entropy (Code Smells) preset for loop module */ -export const ENTROPY_PRESET = `# Task Master Loop - Entropy (Code Smells) +export const ENTROPY_PRESET = `# Taskmaster Loop - Entropy (Code Smells) Find code smells and clean them up. ONE cleanup per session. diff --git a/packages/tm-core/src/modules/loop/presets/linting.ts b/packages/tm-core/src/modules/loop/presets/linting.ts index d33df6f0..181d8778 100644 --- a/packages/tm-core/src/modules/loop/presets/linting.ts +++ b/packages/tm-core/src/modules/loop/presets/linting.ts @@ -1,7 +1,7 @@ /** - * Linting preset for Task Master loop - fix lint and type errors + * Linting preset for Taskmaster loop - fix lint and type errors */ -export const LINTING_PRESET = `# Task Master Loop - Linting +export const LINTING_PRESET = `# Taskmaster Loop - Linting Fix lint errors and type errors one by one. ONE fix per session. diff --git a/packages/tm-core/src/modules/loop/presets/test-coverage.ts b/packages/tm-core/src/modules/loop/presets/test-coverage.ts index 3a2c625a..a7491917 100644 --- a/packages/tm-core/src/modules/loop/presets/test-coverage.ts +++ b/packages/tm-core/src/modules/loop/presets/test-coverage.ts @@ -1,7 +1,7 @@ /** - * Test coverage preset for Task Master loop - writing meaningful tests + * Test coverage preset for Taskmaster loop - writing meaningful tests */ -export const TEST_COVERAGE_PRESET = `# Task Master Loop - Test Coverage +export const TEST_COVERAGE_PRESET = `# Taskmaster Loop - Test Coverage Find uncovered code and write meaningful tests. ONE test per session. diff --git a/packages/tm-core/src/modules/loop/services/loop.service.spec.ts b/packages/tm-core/src/modules/loop/services/loop.service.spec.ts index 48e9ab92..640ab8a7 100644 --- a/packages/tm-core/src/modules/loop/services/loop.service.spec.ts +++ b/packages/tm-core/src/modules/loop/services/loop.service.spec.ts @@ -394,7 +394,7 @@ describe('LoopService', () => { // Uses appendFile instead of writeFile to preserve existing progress expect(fsPromises.appendFile).toHaveBeenCalledWith( '/test/progress.txt', - expect.stringContaining('# Task Master Loop Progress'), + expect.stringContaining('# Taskmaster Loop Progress'), 'utf-8' ); }); @@ -619,12 +619,13 @@ describe('LoopService', () => { expect(header).toContain('@/test/progress.txt'); }); - it('should include tasks file reference', () => { + it('should NOT include tasks file reference (preset controls task source)', () => { const header = buildContextHeader( { iterations: 1, progressFile: '/test/progress.txt' }, 1 ); - expect(header).toContain('@.taskmaster/tasks/tasks.json'); + // tasks.json intentionally excluded - let preset control task source to avoid confusion + expect(header).not.toContain('tasks.json'); }); it('should include tag filter when provided', () => { diff --git a/packages/tm-core/src/modules/loop/services/loop.service.ts b/packages/tm-core/src/modules/loop/services/loop.service.ts index fd08d6d6..3533d482 100644 --- a/packages/tm-core/src/modules/loop/services/loop.service.ts +++ b/packages/tm-core/src/modules/loop/services/loop.service.ts @@ -2,13 +2,15 @@ * @fileoverview Loop Service - Orchestrates running Claude Code iterations (sandbox or CLI mode) */ -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { appendFile, mkdir, readFile } from 'node:fs/promises'; import path from 'node:path'; +import { getLogger } from '../../../common/logger/index.js'; import { PRESETS, isPreset as checkIsPreset } from '../presets/index.js'; import type { LoopConfig, LoopIteration, + LoopOutputCallbacks, LoopPreset, LoopResult } from '../types.js'; @@ -19,6 +21,7 @@ export interface LoopServiceOptions { export class LoopService { private readonly projectRoot: string; + private readonly logger = getLogger('LoopService'); private _isRunning = false; constructor(options: LoopServiceOptions) { @@ -109,6 +112,20 @@ export class LoopService { /** Run a loop with the given configuration */ async run(config: LoopConfig): Promise { + // Validate incompatible options early - fail once, not per iteration + if (config.verbose && config.sandbox) { + const errorMsg = + 'Verbose mode is not supported with sandbox mode. Use --verbose without --sandbox, or remove --verbose.'; + this.reportError(config.callbacks, errorMsg); + return { + iterations: [], + totalIterations: 0, + tasksCompleted: 0, + finalStatus: 'error', + errorMessage: errorMsg + }; + } + this._isRunning = true; const iterations: LoopIteration[] = []; let tasksCompleted = 0; @@ -116,18 +133,23 @@ export class LoopService { await this.initProgressFile(config); for (let i = 1; i <= config.iterations && this._isRunning; i++) { - // Show iteration header - console.log(); - console.log(`━━━ Iteration ${i} of ${config.iterations} ━━━`); + // Notify presentation layer of iteration start + config.callbacks?.onIterationStart?.(i, config.iterations); const prompt = await this.buildPrompt(config, i); - const iteration = this.executeIteration( + const iteration = await this.executeIteration( prompt, i, - config.sandbox ?? false + config.sandbox ?? false, + config.includeOutput ?? false, + config.verbose ?? false, + config.callbacks ); iterations.push(iteration); + // Notify presentation layer of iteration completion + config.callbacks?.onIterationEnd?.(iteration); + // Check for early exit conditions if (iteration.status === 'complete') { return this.finalize( @@ -177,21 +199,41 @@ export class LoopService { return result; } + /** + * Report an error via callback if provided, otherwise log to the logger. + * Ensures errors are never silently swallowed when callbacks aren't configured. + */ + private reportError( + callbacks: LoopOutputCallbacks | undefined, + message: string, + severity: 'warning' | 'error' = 'error' + ): void { + if (callbacks?.onError) { + callbacks.onError(message, severity); + } else if (severity === 'warning') { + this.logger.warn(message); + } else { + this.logger.error(message); + } + } + private async initProgressFile(config: LoopConfig): Promise { await mkdir(path.dirname(config.progressFile), { recursive: true }); - const tagLine = config.tag ? `# Tag: ${config.tag}\n` : ''; + const lines = [ + '# Taskmaster Loop Progress', + `# Started: ${new Date().toISOString()}`, + ...(config.brief ? [`# Brief: ${config.brief}`] : []), + `# Preset: ${config.prompt}`, + `# Max Iterations: ${config.iterations}`, + ...(config.tag ? [`# Tag: ${config.tag}`] : []), + '', + '---', + '' + ]; // Append to existing progress file instead of overwriting await appendFile( config.progressFile, - ` -# Task Master Loop Progress -# Started: ${new Date().toISOString()} -# Preset: ${config.prompt} -# Max Iterations: ${config.iterations} -${tagLine} ---- - -`, + '\n' + lines.join('\n') + '\n', 'utf-8' ); } @@ -230,7 +272,8 @@ ${tagLine} private buildContextHeader(config: LoopConfig, iteration: number): string { const tagInfo = config.tag ? ` (tag: ${config.tag})` : ''; - return `@${config.progressFile} @.taskmaster/tasks/tasks.json @CLAUDE.md + // Note: tasks.json reference removed - let the preset control task source to avoid confusion + return `@${config.progressFile} @CLAUDE.md Loop iteration ${iteration} of ${config.iterations}${tagInfo}`; } @@ -262,63 +305,56 @@ Loop iteration ${iteration} of ${config.iterations}${tagInfo}`; return { status: 'success' }; } - private executeIteration( + private async executeIteration( prompt: string, iterationNum: number, - sandbox: boolean - ): LoopIteration { + sandbox: boolean, + includeOutput = false, + verbose = false, + callbacks?: LoopOutputCallbacks + ): Promise { const startTime = Date.now(); - - // Use docker sandbox or plain claude based on config const command = sandbox ? 'docker' : 'claude'; - const args = sandbox - ? ['sandbox', 'run', 'claude', '-p', prompt] - : ['-p', prompt, '--dangerously-skip-permissions']; + if (verbose) { + return this.executeVerboseIteration( + prompt, + iterationNum, + command, + sandbox, + includeOutput, + startTime, + callbacks + ); + } + + const args = this.buildCommandArgs(prompt, sandbox, false); const result = spawnSync(command, args, { cwd: this.projectRoot, encoding: 'utf-8', - maxBuffer: 50 * 1024 * 1024, // 50MB buffer + maxBuffer: 50 * 1024 * 1024, stdio: ['inherit', 'pipe', 'pipe'] }); - // Check for spawn-level errors (command not found, permission denied, etc.) if (result.error) { - const code = (result.error as NodeJS.ErrnoException).code; - let errorMessage: string; - - if (code === 'ENOENT') { - errorMessage = sandbox - ? 'Docker is not installed. Install Docker Desktop to use --sandbox mode.' - : 'Claude CLI is not installed. Install with: npm install -g @anthropic-ai/claude-code'; - } else if (code === 'EACCES') { - errorMessage = `Permission denied executing '${command}'`; - } else { - errorMessage = `Failed to execute '${command}': ${result.error.message}`; - } - - console.error(`[Loop Error] ${errorMessage}`); - return { - iteration: iterationNum, - status: 'error', - duration: Date.now() - startTime, - message: errorMessage - }; + const errorMessage = this.formatCommandError( + result.error, + command, + sandbox + ); + this.reportError(callbacks, errorMessage); + return this.createErrorIteration(iterationNum, startTime, errorMessage); } const output = (result.stdout || '') + (result.stderr || ''); + if (output) { + callbacks?.onOutput?.(output); + } - // Print output to console (spawnSync with pipe captures but doesn't display) - if (output) console.log(output); - - // Handle null status (spawn failed but no error object - shouldn't happen but be safe) if (result.status === null) { - return { - iteration: iterationNum, - status: 'error', - duration: Date.now() - startTime, - message: 'Command terminated abnormally (no exit code)' - }; + const errorMsg = 'Command terminated abnormally (no exit code)'; + this.reportError(callbacks, errorMsg); + return this.createErrorIteration(iterationNum, startTime, errorMsg); } const { status, message } = this.parseCompletion(output, result.status); @@ -326,7 +362,282 @@ Loop iteration ${iteration} of ${config.iterations}${tagInfo}`; iteration: iterationNum, status, duration: Date.now() - startTime, + message, + ...(includeOutput && { output }) + }; + } + + /** + * Execute an iteration with verbose output (shows Claude's work in real-time). + * Uses Claude's stream-json format to display assistant messages as they arrive. + * @param prompt - The prompt to send to Claude + * @param iterationNum - Current iteration number (1-indexed) + * @param command - The command to execute ('claude' or 'docker') + * @param sandbox - Whether running in Docker sandbox mode + * @param includeOutput - Whether to include full output in the result + * @param startTime - Timestamp when iteration started (for duration calculation) + * @param callbacks - Optional callbacks for presentation layer output + * @returns Promise resolving to the iteration result + */ + private executeVerboseIteration( + prompt: string, + iterationNum: number, + command: string, + sandbox: boolean, + includeOutput: boolean, + startTime: number, + callbacks?: LoopOutputCallbacks + ): Promise { + const args = this.buildCommandArgs(prompt, sandbox, true); + + return new Promise((resolve) => { + // Prevent multiple resolutions from race conditions between error/close events + let isResolved = false; + const resolveOnce = (result: LoopIteration): void => { + if (!isResolved) { + isResolved = true; + resolve(result); + } + }; + + const child = spawn(command, args, { + cwd: this.projectRoot, + stdio: ['inherit', 'pipe', 'pipe'] + }); + + // Track stdout completion to handle race between data and close events + let stdoutEnded = false; + let finalResult = ''; + let buffer = ''; + + const processLine = (line: string): void => { + if (!line.startsWith('{')) return; + + try { + const event = JSON.parse(line); + + // Validate event structure before accessing properties + if (!this.isValidStreamEvent(event)) { + return; + } + + this.handleStreamEvent(event, callbacks); + + // Capture final result for includeOutput feature + if (event.type === 'result') { + finalResult = typeof event.result === 'string' ? event.result : ''; + } + } catch (error) { + // Log malformed JSON for debugging (non-JSON lines like system output are expected) + if (line.trim().startsWith('{')) { + const parseError = `Failed to parse JSON event: ${error instanceof Error ? error.message : 'Unknown error'}. Line: ${line.substring(0, 100)}...`; + this.reportError(callbacks, parseError, 'warning'); + } + } + }; + + // Handle null stdout (shouldn't happen with pipe, but be defensive) + if (!child.stdout) { + resolveOnce( + this.createErrorIteration( + iterationNum, + startTime, + 'Failed to capture stdout from child process' + ) + ); + return; + } + + child.stdout.on('data', (data: Buffer) => { + try { + const lines = this.processBufferedLines( + buffer, + data.toString('utf-8') + ); + buffer = lines.remaining; + for (const line of lines.complete) { + processLine(line); + } + } catch (error) { + this.reportError( + callbacks, + `Failed to process stdout data: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'warning' + ); + } + }); + + child.stdout.on('end', () => { + stdoutEnded = true; + // Process any remaining buffer when stdout ends + if (buffer) { + processLine(buffer); + buffer = ''; + } + }); + + child.stderr?.on('data', (data: Buffer) => { + const stderrText = data.toString('utf-8'); + callbacks?.onStderr?.(iterationNum, stderrText); + }); + + child.on('error', (error: NodeJS.ErrnoException) => { + const errorMessage = this.formatCommandError(error, command, sandbox); + this.reportError(callbacks, errorMessage); + + // Cleanup: remove listeners and kill process if still running + child.stdout?.removeAllListeners(); + child.stderr?.removeAllListeners(); + if (!child.killed) { + try { + child.kill('SIGTERM'); + } catch { + // Process may have already exited + } + } + + resolveOnce( + this.createErrorIteration(iterationNum, startTime, errorMessage) + ); + }); + + child.on('close', (exitCode: number | null) => { + // Process remaining buffer only if stdout hasn't already ended + if (!stdoutEnded && buffer) { + processLine(buffer); + } + + if (exitCode === null) { + const errorMsg = 'Command terminated abnormally (no exit code)'; + this.reportError(callbacks, errorMsg); + resolveOnce( + this.createErrorIteration(iterationNum, startTime, errorMsg) + ); + return; + } + + const { status, message } = this.parseCompletion(finalResult, exitCode); + resolveOnce({ + iteration: iterationNum, + status, + duration: Date.now() - startTime, + message, + ...(includeOutput && { output: finalResult }) + }); + }); + }); + } + + /** + * Validate that a parsed JSON object has the expected stream event structure. + */ + private isValidStreamEvent(event: unknown): event is { + type: string; + message?: { + content?: Array<{ type: string; text?: string; name?: string }>; + }; + result?: string; + } { + if (!event || typeof event !== 'object') { + return false; + } + + const e = event as Record; + if (!('type' in e) || typeof e.type !== 'string') { + return false; + } + + // Validate message structure if present + if ('message' in e && e.message !== undefined) { + if (typeof e.message !== 'object' || e.message === null) { + return false; + } + const msg = e.message as Record; + if ('content' in msg && !Array.isArray(msg.content)) { + return false; + } + } + + return true; + } + + private buildCommandArgs( + prompt: string, + sandbox: boolean, + verbose: boolean + ): string[] { + if (sandbox) { + return ['sandbox', 'run', 'claude', '-p', prompt]; + } + + const args = ['-p', prompt, '--dangerously-skip-permissions']; + if (verbose) { + // Use stream-json format to show Claude's work in real-time + args.push('--output-format', 'stream-json', '--verbose'); + } + return args; + } + + private formatCommandError( + error: NodeJS.ErrnoException, + command: string, + sandbox: boolean + ): string { + if (error.code === 'ENOENT') { + return sandbox + ? 'Docker is not installed. Install Docker Desktop to use --sandbox mode.' + : 'Claude CLI is not installed. Install with: npm install -g @anthropic-ai/claude-code'; + } + + if (error.code === 'EACCES') { + return `Permission denied executing '${command}'`; + } + + return `Failed to execute '${command}': ${error.message}`; + } + + private createErrorIteration( + iterationNum: number, + startTime: number, + message: string + ): LoopIteration { + return { + iteration: iterationNum, + status: 'error', + duration: Date.now() - startTime, message }; } + + private handleStreamEvent( + event: { + type: string; + message?: { + content?: Array<{ type: string; text?: string; name?: string }>; + }; + }, + callbacks?: LoopOutputCallbacks + ): void { + if (event.type !== 'assistant' || !event.message?.content) return; + + for (const block of event.message.content) { + if (block.type === 'text' && block.text) { + callbacks?.onText?.(block.text); + } else if (block.type === 'tool_use' && block.name) { + callbacks?.onToolUse?.(block.name); + } + } + } + + private processBufferedLines( + buffer: string, + newData: string + ): { complete: string[]; remaining: string } { + const combined = buffer + newData; + const lines = combined.split('\n'); + return { + complete: lines.slice(0, -1), + remaining: lines[lines.length - 1] + }; + } } diff --git a/packages/tm-core/src/modules/loop/types.ts b/packages/tm-core/src/modules/loop/types.ts index 452deeec..b5375d53 100644 --- a/packages/tm-core/src/modules/loop/types.ts +++ b/packages/tm-core/src/modules/loop/types.ts @@ -12,6 +12,33 @@ export type LoopPreset = | 'duplication' | 'entropy'; +/** + * Output callbacks for loop execution. + * These allow the caller (CLI/MCP) to handle presentation while + * the service stays focused on business logic. + * + * Callback modes: + * - `onIterationStart`, `onIterationEnd`, `onError`, `onStderr`: Called in both verbose and non-verbose modes + * - `onText`, `onToolUse`: Called only in VERBOSE mode (--verbose flag) + * - `onOutput`: Called only in NON-VERBOSE mode (default) + */ +export interface LoopOutputCallbacks { + /** Called at the start of each iteration (both modes) */ + onIterationStart?: (iteration: number, total: number) => void; + /** Called when Claude outputs text (VERBOSE MODE ONLY) */ + onText?: (text: string) => void; + /** Called when Claude invokes a tool (VERBOSE MODE ONLY) */ + onToolUse?: (toolName: string) => void; + /** Called when an error occurs (both modes) */ + onError?: (message: string, severity?: 'warning' | 'error') => void; + /** Called for stderr output (both modes) */ + onStderr?: (iteration: number, text: string) => void; + /** Called when non-verbose iteration completes with output (NON-VERBOSE MODE ONLY) */ + onOutput?: (output: string) => void; + /** Called at the end of each iteration with the result (both modes) */ + onIterationEnd?: (iteration: LoopIteration) => void; +} + /** * Configuration options for a loop execution */ @@ -28,6 +55,39 @@ export interface LoopConfig { tag?: string; /** Run Claude in Docker sandbox mode (default: false) */ sandbox?: boolean; + /** + * Include full Claude output in iteration results (default: false) + * + * When true: `LoopIteration.output` will contain full stdout+stderr text + * When false: `LoopIteration.output` will be undefined (saves memory) + * + * Can be combined with `verbose=true` to both display and capture output. + * Note: Output can be large (up to 50MB per iteration). + */ + includeOutput?: boolean; + /** + * Show Claude's work in real-time instead of just the result (default: false) + * + * When true: Output appears as Claude generates it (shows thinking, tool calls) + * When false: Output appears only after iteration completes + * + * Independent of `includeOutput` - controls display timing, not capture. + * Note: NOT compatible with `sandbox=true` (will return error). + */ + verbose?: boolean; + /** + * Brief title describing the current initiative/goal (optional) + * + * If provided, included in the progress file header to give Claude + * context about the bigger picture across iterations. + * Example: "Implement streaming output for loop command" + */ + brief?: string; + /** + * Output callbacks for presentation layer (CLI/MCP). + * If not provided, the service runs silently (no console output). + */ + callbacks?: LoopOutputCallbacks; } /** @@ -44,6 +104,15 @@ export interface LoopIteration { message?: string; /** Duration of this iteration in milliseconds */ duration?: number; + /** + * Full Claude output text + * + * ONLY present when `LoopConfig.includeOutput=true`. + * Contains concatenated stdout and stderr from Claude CLI execution. + * May include ANSI color codes and tool call output. + * Can be large - use `includeOutput=false` to save memory. + */ + output?: string; } /** @@ -58,4 +127,6 @@ export interface LoopResult { tasksCompleted: number; /** Final status of the loop */ finalStatus: 'all_complete' | 'max_iterations' | 'blocked' | 'error'; + /** Error message when finalStatus is 'error' (optional) */ + errorMessage?: string; } diff --git a/tests/setup.js b/tests/setup.js index 52af0669..420d17ac 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -51,3 +51,16 @@ if (process.env.SILENCE_CONSOLE === 'true') { error: () => {} }; } + +// Clean up signal-exit listeners after all tests to prevent open handle warnings +// This is needed because packages like proper-lockfile register signal handlers +afterAll(async () => { + // Give any pending async operations time to complete + await new Promise((resolve) => setImmediate(resolve)); + + // Clean up any registered signal handlers from signal-exit + const listeners = ['SIGINT', 'SIGTERM', 'SIGHUP']; + for (const signal of listeners) { + process.removeAllListeners(signal); + } +});