mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-29 22:02:04 +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'),
|
getStorageType: vi.fn().mockReturnValue('local'),
|
||||||
getNext: vi.fn().mockResolvedValue({ id: '1', title: 'Test Task' }),
|
getNext: vi.fn().mockResolvedValue({ id: '1', title: 'Test Task' }),
|
||||||
getCount: vi.fn().mockResolvedValue(0)
|
getCount: vi.fn().mockResolvedValue(0)
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
getContext: vi.fn().mockReturnValue(null)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
type LoopConfig,
|
type LoopConfig,
|
||||||
|
type LoopIteration,
|
||||||
|
type LoopOutputCallbacks,
|
||||||
type LoopResult,
|
type LoopResult,
|
||||||
PRESET_NAMES,
|
PRESET_NAMES,
|
||||||
type TmCore,
|
type TmCore,
|
||||||
@@ -23,6 +25,8 @@ export interface LoopCommandOptions {
|
|||||||
tag?: string;
|
tag?: string;
|
||||||
project?: string;
|
project?: string;
|
||||||
sandbox?: boolean;
|
sandbox?: boolean;
|
||||||
|
output?: boolean;
|
||||||
|
verbose?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LoopCommand extends Command {
|
export class LoopCommand extends Command {
|
||||||
@@ -49,6 +53,11 @@ export class LoopCommand extends Command {
|
|||||||
'Project root directory (auto-detected if not provided)'
|
'Project root directory (auto-detected if not provided)'
|
||||||
)
|
)
|
||||||
.option('--sandbox', 'Run Claude in Docker sandbox mode')
|
.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));
|
.action((options: LoopCommandOptions) => this.execute(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +118,21 @@ export class LoopCommand extends Command {
|
|||||||
}
|
}
|
||||||
console.log();
|
console.log();
|
||||||
|
|
||||||
|
// Auto-detect brief name from auth context (if available)
|
||||||
|
const briefName = this.tmCore.auth.getContext()?.briefName;
|
||||||
|
|
||||||
const config: Partial<LoopConfig> = {
|
const config: Partial<LoopConfig> = {
|
||||||
iterations,
|
iterations,
|
||||||
prompt,
|
prompt,
|
||||||
progressFile,
|
progressFile,
|
||||||
tag: options.tag,
|
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);
|
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 {
|
private displayResult(result: LoopResult): void {
|
||||||
console.log();
|
console.log();
|
||||||
console.log(chalk.bold('Loop Complete'));
|
console.log(chalk.bold('Loop Complete'));
|
||||||
@@ -168,6 +227,9 @@ export class LoopCommand extends Command {
|
|||||||
console.log(`Total iterations: ${result.totalIterations}`);
|
console.log(`Total iterations: ${result.totalIterations}`);
|
||||||
console.log(`Tasks completed: ${result.tasksCompleted}`);
|
console.log(`Tasks completed: ${result.tasksCompleted}`);
|
||||||
console.log(`Final status: ${this.formatStatus(result.finalStatus)}`);
|
console.log(`Final status: ${this.formatStatus(result.finalStatus)}`);
|
||||||
|
if (result.errorMessage) {
|
||||||
|
console.log(chalk.red(`Error: ${result.errorMessage}`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatStatus(status: LoopResult['finalStatus']): string {
|
private formatStatus(status: LoopResult['finalStatus']): string {
|
||||||
|
|||||||
@@ -155,7 +155,8 @@ export type {
|
|||||||
LoopPreset,
|
LoopPreset,
|
||||||
LoopConfig,
|
LoopConfig,
|
||||||
LoopIteration,
|
LoopIteration,
|
||||||
LoopResult
|
LoopResult,
|
||||||
|
LoopOutputCallbacks
|
||||||
} from './modules/loop/index.js';
|
} from './modules/loop/index.js';
|
||||||
export { LoopDomain, PRESET_NAMES } from './modules/loop/index.js';
|
export { LoopDomain, PRESET_NAMES } from './modules/loop/index.js';
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export type {
|
|||||||
LoopPreset,
|
LoopPreset,
|
||||||
LoopConfig,
|
LoopConfig,
|
||||||
LoopIteration,
|
LoopIteration,
|
||||||
LoopResult
|
LoopResult,
|
||||||
|
LoopOutputCallbacks
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
// Presets - content and helpers
|
// Presets - content and helpers
|
||||||
|
|||||||
@@ -190,7 +190,11 @@ export class LoopDomain {
|
|||||||
path.join(this.projectRoot, '.taskmaster', 'progress.txt'),
|
path.join(this.projectRoot, '.taskmaster', 'progress.txt'),
|
||||||
sleepSeconds: partial.sleepSeconds ?? 5,
|
sleepSeconds: partial.sleepSeconds ?? 5,
|
||||||
tag: partial.tag,
|
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`] = `
|
exports[`Preset Snapshots > default preset matches snapshot 1`] = `
|
||||||
"SETUP: If task-master command not found, run: npm i -g task-master-ai
|
"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:
|
PROCESS:
|
||||||
1. Run task-master next (or use MCP) to get the next available task/subtask.
|
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`] = `
|
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.
|
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`] = `
|
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.
|
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`] = `
|
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.
|
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`] = `
|
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.
|
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
|
* 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
|
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:
|
PROCESS:
|
||||||
1. Run task-master next (or use MCP) to get the next available task/subtask.
|
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.
|
Find duplicated code and refactor into shared utilities. ONE refactor per session.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @fileoverview Entropy (Code Smells) preset for loop module
|
* @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.
|
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.
|
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.
|
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
|
// Uses appendFile instead of writeFile to preserve existing progress
|
||||||
expect(fsPromises.appendFile).toHaveBeenCalledWith(
|
expect(fsPromises.appendFile).toHaveBeenCalledWith(
|
||||||
'/test/progress.txt',
|
'/test/progress.txt',
|
||||||
expect.stringContaining('# Task Master Loop Progress'),
|
expect.stringContaining('# Taskmaster Loop Progress'),
|
||||||
'utf-8'
|
'utf-8'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -619,12 +619,13 @@ describe('LoopService', () => {
|
|||||||
expect(header).toContain('@/test/progress.txt');
|
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(
|
const header = buildContextHeader(
|
||||||
{ iterations: 1, progressFile: '/test/progress.txt' },
|
{ iterations: 1, progressFile: '/test/progress.txt' },
|
||||||
1
|
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', () => {
|
it('should include tag filter when provided', () => {
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
* @fileoverview Loop Service - Orchestrates running Claude Code iterations (sandbox or CLI mode)
|
* @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 { appendFile, mkdir, readFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { getLogger } from '../../../common/logger/index.js';
|
||||||
import { PRESETS, isPreset as checkIsPreset } from '../presets/index.js';
|
import { PRESETS, isPreset as checkIsPreset } from '../presets/index.js';
|
||||||
import type {
|
import type {
|
||||||
LoopConfig,
|
LoopConfig,
|
||||||
LoopIteration,
|
LoopIteration,
|
||||||
|
LoopOutputCallbacks,
|
||||||
LoopPreset,
|
LoopPreset,
|
||||||
LoopResult
|
LoopResult
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
@@ -19,6 +21,7 @@ export interface LoopServiceOptions {
|
|||||||
|
|
||||||
export class LoopService {
|
export class LoopService {
|
||||||
private readonly projectRoot: string;
|
private readonly projectRoot: string;
|
||||||
|
private readonly logger = getLogger('LoopService');
|
||||||
private _isRunning = false;
|
private _isRunning = false;
|
||||||
|
|
||||||
constructor(options: LoopServiceOptions) {
|
constructor(options: LoopServiceOptions) {
|
||||||
@@ -109,6 +112,20 @@ export class LoopService {
|
|||||||
|
|
||||||
/** Run a loop with the given configuration */
|
/** Run a loop with the given configuration */
|
||||||
async run(config: LoopConfig): Promise<LoopResult> {
|
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;
|
this._isRunning = true;
|
||||||
const iterations: LoopIteration[] = [];
|
const iterations: LoopIteration[] = [];
|
||||||
let tasksCompleted = 0;
|
let tasksCompleted = 0;
|
||||||
@@ -116,18 +133,23 @@ export class LoopService {
|
|||||||
await this.initProgressFile(config);
|
await this.initProgressFile(config);
|
||||||
|
|
||||||
for (let i = 1; i <= config.iterations && this._isRunning; i++) {
|
for (let i = 1; i <= config.iterations && this._isRunning; i++) {
|
||||||
// Show iteration header
|
// Notify presentation layer of iteration start
|
||||||
console.log();
|
config.callbacks?.onIterationStart?.(i, config.iterations);
|
||||||
console.log(`━━━ Iteration ${i} of ${config.iterations} ━━━`);
|
|
||||||
|
|
||||||
const prompt = await this.buildPrompt(config, i);
|
const prompt = await this.buildPrompt(config, i);
|
||||||
const iteration = this.executeIteration(
|
const iteration = await this.executeIteration(
|
||||||
prompt,
|
prompt,
|
||||||
i,
|
i,
|
||||||
config.sandbox ?? false
|
config.sandbox ?? false,
|
||||||
|
config.includeOutput ?? false,
|
||||||
|
config.verbose ?? false,
|
||||||
|
config.callbacks
|
||||||
);
|
);
|
||||||
iterations.push(iteration);
|
iterations.push(iteration);
|
||||||
|
|
||||||
|
// Notify presentation layer of iteration completion
|
||||||
|
config.callbacks?.onIterationEnd?.(iteration);
|
||||||
|
|
||||||
// Check for early exit conditions
|
// Check for early exit conditions
|
||||||
if (iteration.status === 'complete') {
|
if (iteration.status === 'complete') {
|
||||||
return this.finalize(
|
return this.finalize(
|
||||||
@@ -177,21 +199,41 @@ export class LoopService {
|
|||||||
return result;
|
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> {
|
private async initProgressFile(config: LoopConfig): Promise<void> {
|
||||||
await mkdir(path.dirname(config.progressFile), { recursive: true });
|
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
|
// Append to existing progress file instead of overwriting
|
||||||
await appendFile(
|
await appendFile(
|
||||||
config.progressFile,
|
config.progressFile,
|
||||||
`
|
'\n' + lines.join('\n') + '\n',
|
||||||
# Task Master Loop Progress
|
|
||||||
# Started: ${new Date().toISOString()}
|
|
||||||
# Preset: ${config.prompt}
|
|
||||||
# Max Iterations: ${config.iterations}
|
|
||||||
${tagLine}
|
|
||||||
---
|
|
||||||
|
|
||||||
`,
|
|
||||||
'utf-8'
|
'utf-8'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -230,7 +272,8 @@ ${tagLine}
|
|||||||
|
|
||||||
private buildContextHeader(config: LoopConfig, iteration: number): string {
|
private buildContextHeader(config: LoopConfig, iteration: number): string {
|
||||||
const tagInfo = config.tag ? ` (tag: ${config.tag})` : '';
|
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}`;
|
Loop iteration ${iteration} of ${config.iterations}${tagInfo}`;
|
||||||
}
|
}
|
||||||
@@ -262,63 +305,56 @@ Loop iteration ${iteration} of ${config.iterations}${tagInfo}`;
|
|||||||
return { status: 'success' };
|
return { status: 'success' };
|
||||||
}
|
}
|
||||||
|
|
||||||
private executeIteration(
|
private async executeIteration(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
iterationNum: number,
|
iterationNum: number,
|
||||||
sandbox: boolean
|
sandbox: boolean,
|
||||||
): LoopIteration {
|
includeOutput = false,
|
||||||
|
verbose = false,
|
||||||
|
callbacks?: LoopOutputCallbacks
|
||||||
|
): Promise<LoopIteration> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
// Use docker sandbox or plain claude based on config
|
|
||||||
const command = sandbox ? 'docker' : 'claude';
|
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, {
|
const result = spawnSync(command, args, {
|
||||||
cwd: this.projectRoot,
|
cwd: this.projectRoot,
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
maxBuffer: 50 * 1024 * 1024, // 50MB buffer
|
maxBuffer: 50 * 1024 * 1024,
|
||||||
stdio: ['inherit', 'pipe', 'pipe']
|
stdio: ['inherit', 'pipe', 'pipe']
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for spawn-level errors (command not found, permission denied, etc.)
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
const code = (result.error as NodeJS.ErrnoException).code;
|
const errorMessage = this.formatCommandError(
|
||||||
let errorMessage: string;
|
result.error,
|
||||||
|
command,
|
||||||
if (code === 'ENOENT') {
|
sandbox
|
||||||
errorMessage = sandbox
|
);
|
||||||
? 'Docker is not installed. Install Docker Desktop to use --sandbox mode.'
|
this.reportError(callbacks, errorMessage);
|
||||||
: 'Claude CLI is not installed. Install with: npm install -g @anthropic-ai/claude-code';
|
return this.createErrorIteration(iterationNum, startTime, errorMessage);
|
||||||
} 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 output = (result.stdout || '') + (result.stderr || '');
|
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) {
|
if (result.status === null) {
|
||||||
return {
|
const errorMsg = 'Command terminated abnormally (no exit code)';
|
||||||
iteration: iterationNum,
|
this.reportError(callbacks, errorMsg);
|
||||||
status: 'error',
|
return this.createErrorIteration(iterationNum, startTime, errorMsg);
|
||||||
duration: Date.now() - startTime,
|
|
||||||
message: 'Command terminated abnormally (no exit code)'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status, message } = this.parseCompletion(output, result.status);
|
const { status, message } = this.parseCompletion(output, result.status);
|
||||||
@@ -326,7 +362,282 @@ Loop iteration ${iteration} of ${config.iterations}${tagInfo}`;
|
|||||||
iteration: iterationNum,
|
iteration: iterationNum,
|
||||||
status,
|
status,
|
||||||
duration: Date.now() - startTime,
|
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
|
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'
|
| 'duplication'
|
||||||
| 'entropy';
|
| '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
|
* Configuration options for a loop execution
|
||||||
*/
|
*/
|
||||||
@@ -28,6 +55,39 @@ export interface LoopConfig {
|
|||||||
tag?: string;
|
tag?: string;
|
||||||
/** Run Claude in Docker sandbox mode (default: false) */
|
/** Run Claude in Docker sandbox mode (default: false) */
|
||||||
sandbox?: boolean;
|
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;
|
message?: string;
|
||||||
/** Duration of this iteration in milliseconds */
|
/** Duration of this iteration in milliseconds */
|
||||||
duration?: number;
|
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;
|
tasksCompleted: number;
|
||||||
/** Final status of the loop */
|
/** Final status of the loop */
|
||||||
finalStatus: 'all_complete' | 'max_iterations' | 'blocked' | 'error';
|
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: () => {}
|
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