mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat(loop): add streaming output mode with --stream flag (#1605)
This commit is contained in:
9
.changeset/cuddly-wings-drop.md
Normal file
9
.changeset/cuddly-wings-drop.md
Normal 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)
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ export type {
|
||||
LoopPreset,
|
||||
LoopConfig,
|
||||
LoopIteration,
|
||||
LoopResult
|
||||
LoopResult,
|
||||
LoopOutputCallbacks
|
||||
} from './types.js';
|
||||
|
||||
// Presets - content and helpers
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user