feat(loop): add streaming output mode with --stream flag (#1605)

This commit is contained in:
Ralph Khreish
2026-01-25 11:50:07 +01:00
committed by GitHub
parent 28c491cca1
commit efedc85cb1
16 changed files with 554 additions and 78 deletions

View File

@@ -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)

View File

@@ -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)
}
};

View File

@@ -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<LoopConfig> = {
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 {

View File

@@ -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';

View File

@@ -15,7 +15,8 @@ export type {
LoopPreset,
LoopConfig,
LoopIteration,
LoopResult
LoopResult,
LoopOutputCallbacks
} from './types.js';
// Presets - content and helpers

View File

@@ -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
};
}
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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', () => {

View File

@@ -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<LoopResult> {
// 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<void> {
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<LoopIteration> {
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<LoopIteration> {
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<string, unknown>;
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<string, unknown>;
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]
};
}
}

View File

@@ -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;
}

View File

@@ -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);
}
});