@litmers/cursorflow-orchestrator 0.1.8 ā 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +97 -321
- package/commands/cursorflow-doctor.md +28 -0
- package/commands/cursorflow-monitor.md +59 -101
- package/commands/cursorflow-prepare.md +25 -2
- package/commands/cursorflow-resume.md +11 -0
- package/commands/cursorflow-run.md +109 -100
- package/commands/cursorflow-signal.md +85 -14
- package/dist/cli/clean.d.ts +3 -1
- package/dist/cli/clean.js +122 -8
- package/dist/cli/clean.js.map +1 -1
- package/dist/cli/index.js +20 -20
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/monitor.js +39 -1
- package/dist/cli/monitor.js.map +1 -1
- package/dist/core/runner.d.ts +9 -1
- package/dist/core/runner.js +139 -23
- package/dist/core/runner.js.map +1 -1
- package/dist/utils/types.d.ts +9 -0
- package/package.json +1 -1
- package/scripts/patches/test-cursor-agent.js +203 -0
- package/src/cli/clean.ts +129 -9
- package/src/cli/index.ts +20 -20
- package/src/cli/monitor.ts +40 -1
- package/src/core/runner.ts +164 -23
- package/src/utils/types.ts +9 -0
package/src/cli/clean.ts
CHANGED
|
@@ -1,36 +1,156 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CursorFlow clean command
|
|
2
|
+
* CursorFlow clean command
|
|
3
|
+
*
|
|
4
|
+
* Clean up worktrees, branches, and logs created by CursorFlow
|
|
3
5
|
*/
|
|
4
6
|
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
5
9
|
import * as logger from '../utils/logger';
|
|
10
|
+
import * as git from '../utils/git';
|
|
11
|
+
import { loadConfig, getLogsDir } from '../utils/config';
|
|
6
12
|
|
|
7
13
|
interface CleanOptions {
|
|
8
14
|
type?: string;
|
|
9
15
|
pattern: string | null;
|
|
10
16
|
dryRun: boolean;
|
|
11
17
|
force: boolean;
|
|
18
|
+
all: boolean;
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
function parseArgs(args: string[]): CleanOptions {
|
|
15
22
|
return {
|
|
16
|
-
type: args
|
|
23
|
+
type: args.find(a => ['branches', 'worktrees', 'logs', 'all'].includes(a)),
|
|
17
24
|
pattern: null,
|
|
18
25
|
dryRun: args.includes('--dry-run'),
|
|
19
26
|
force: args.includes('--force'),
|
|
27
|
+
all: args.includes('--all'),
|
|
20
28
|
};
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
async function clean(args: string[]): Promise<void> {
|
|
24
|
-
logger.section('š§¹ Cleaning CursorFlow Resources');
|
|
25
|
-
|
|
26
32
|
const options = parseArgs(args);
|
|
33
|
+
const config = loadConfig();
|
|
34
|
+
const repoRoot = git.getRepoRoot();
|
|
35
|
+
|
|
36
|
+
logger.section('š§¹ Cleaning CursorFlow Resources');
|
|
37
|
+
|
|
38
|
+
const type = options.type || 'all';
|
|
39
|
+
|
|
40
|
+
if (type === 'all') {
|
|
41
|
+
await cleanWorktrees(config, repoRoot, options);
|
|
42
|
+
await cleanBranches(config, repoRoot, options);
|
|
43
|
+
await cleanLogs(config, options);
|
|
44
|
+
} else if (type === 'worktrees') {
|
|
45
|
+
await cleanWorktrees(config, repoRoot, options);
|
|
46
|
+
} else if (type === 'branches') {
|
|
47
|
+
await cleanBranches(config, repoRoot, options);
|
|
48
|
+
} else if (type === 'logs') {
|
|
49
|
+
await cleanLogs(config, options);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
logger.success('\n⨠Cleaning complete!');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function cleanWorktrees(config: any, repoRoot: string, options: CleanOptions) {
|
|
56
|
+
logger.info('\nChecking worktrees...');
|
|
57
|
+
const worktrees = git.listWorktrees(repoRoot);
|
|
27
58
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
59
|
+
const worktreeRoot = path.join(repoRoot, config.worktreeRoot || '_cursorflow/worktrees');
|
|
60
|
+
const toRemove = worktrees.filter(wt => {
|
|
61
|
+
// Skip main worktree
|
|
62
|
+
if (wt.path === repoRoot) return false;
|
|
63
|
+
|
|
64
|
+
const isInsideRoot = wt.path.startsWith(worktreeRoot);
|
|
65
|
+
const hasPrefix = path.basename(wt.path).startsWith(config.worktreePrefix || 'cursorflow-');
|
|
66
|
+
|
|
67
|
+
return isInsideRoot || hasPrefix;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (toRemove.length === 0) {
|
|
71
|
+
logger.info(' No worktrees found to clean.');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const wt of toRemove) {
|
|
76
|
+
if (options.dryRun) {
|
|
77
|
+
logger.info(` [DRY RUN] Would remove worktree: ${wt.path} (${wt.branch || 'no branch'})`);
|
|
78
|
+
} else {
|
|
79
|
+
try {
|
|
80
|
+
logger.info(` Removing worktree: ${wt.path}...`);
|
|
81
|
+
git.removeWorktree(wt.path, { cwd: repoRoot, force: options.force });
|
|
82
|
+
|
|
83
|
+
// Git worktree remove might leave the directory if it has untracked files
|
|
84
|
+
if (fs.existsSync(wt.path)) {
|
|
85
|
+
if (options.force) {
|
|
86
|
+
fs.rmSync(wt.path, { recursive: true, force: true });
|
|
87
|
+
logger.info(` (Forced removal of directory)`);
|
|
88
|
+
} else {
|
|
89
|
+
logger.warn(` Directory still exists: ${wt.path} (contains untracked files). Use --force to delete anyway.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch (e: any) {
|
|
93
|
+
logger.error(` Failed to remove worktree ${wt.path}: ${e.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function cleanBranches(config: any, repoRoot: string, options: CleanOptions) {
|
|
100
|
+
logger.info('\nChecking branches...');
|
|
31
101
|
|
|
32
|
-
|
|
33
|
-
|
|
102
|
+
// List all local branches
|
|
103
|
+
const result = git.runGitResult(['branch', '--list'], { cwd: repoRoot });
|
|
104
|
+
if (!result.success) return;
|
|
105
|
+
|
|
106
|
+
const branches = result.stdout
|
|
107
|
+
.split('\n')
|
|
108
|
+
.map(b => b.replace('*', '').trim())
|
|
109
|
+
.filter(b => b && b !== 'main' && b !== 'master');
|
|
110
|
+
|
|
111
|
+
const prefix = config.branchPrefix || 'feature/';
|
|
112
|
+
const toDelete = branches.filter(b => b.startsWith(prefix));
|
|
113
|
+
|
|
114
|
+
if (toDelete.length === 0) {
|
|
115
|
+
logger.info(' No branches found to clean.');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const branch of toDelete) {
|
|
120
|
+
if (options.dryRun) {
|
|
121
|
+
logger.info(` [DRY RUN] Would delete branch: ${branch}`);
|
|
122
|
+
} else {
|
|
123
|
+
try {
|
|
124
|
+
logger.info(` Deleting branch: ${branch}...`);
|
|
125
|
+
git.deleteBranch(branch, { cwd: repoRoot, force: options.force || options.all });
|
|
126
|
+
} catch (e: any) {
|
|
127
|
+
logger.warn(` Could not delete branch ${branch}: ${e.message}. Use --force if it's not merged.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function cleanLogs(config: any, options: CleanOptions) {
|
|
134
|
+
const logsDir = getLogsDir(config);
|
|
135
|
+
logger.info(`\nChecking logs in ${logsDir}...`);
|
|
136
|
+
|
|
137
|
+
if (!fs.existsSync(logsDir)) {
|
|
138
|
+
logger.info(' Logs directory does not exist.');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (options.dryRun) {
|
|
143
|
+
logger.info(` [DRY RUN] Would remove logs directory: ${logsDir}`);
|
|
144
|
+
} else {
|
|
145
|
+
try {
|
|
146
|
+
logger.info(` Removing logs...`);
|
|
147
|
+
fs.rmSync(logsDir, { recursive: true, force: true });
|
|
148
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
149
|
+
logger.info(` Logs cleared.`);
|
|
150
|
+
} catch (e: any) {
|
|
151
|
+
logger.error(` Failed to clean logs: ${e.message}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
34
154
|
}
|
|
35
155
|
|
|
36
156
|
export = clean;
|
package/src/cli/index.ts
CHANGED
|
@@ -20,45 +20,45 @@ const COMMANDS: Record<string, CommandFn> = {
|
|
|
20
20
|
|
|
21
21
|
function printHelp(): void {
|
|
22
22
|
console.log(`
|
|
23
|
-
|
|
23
|
+
\x1b[1m\x1b[36mCursorFlow\x1b[0m - Git worktree-based parallel AI agent orchestration
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
\x1b[1mUSAGE\x1b[0m
|
|
26
|
+
$ \x1b[32mcursorflow\x1b[0m <command> [options]
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
\x1b[1mCOMMANDS\x1b[0m
|
|
29
|
+
\x1b[33minit\x1b[0m [options] Initialize CursorFlow in project
|
|
30
|
+
\x1b[33mrun\x1b[0m <tasks-dir> [options] Run orchestration (DAG-based)
|
|
31
|
+
\x1b[33mmonitor\x1b[0m [run-dir] [options] \x1b[36mInteractive\x1b[0m lane dashboard
|
|
32
|
+
\x1b[33mclean\x1b[0m <type> [options] Clean branches/worktrees/logs
|
|
33
|
+
\x1b[33mresume\x1b[0m <lane> [options] Resume interrupted lane
|
|
34
|
+
\x1b[33mdoctor\x1b[0m [options] Check environment and preflight
|
|
35
|
+
\x1b[33msignal\x1b[0m <lane> <msg> Directly intervene in a running lane
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
\x1b[1mGLOBAL OPTIONS\x1b[0m
|
|
37
38
|
--config <path> Config file path
|
|
38
39
|
--help, -h Show help
|
|
39
40
|
--version, -v Show version
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
cursorflow doctor
|
|
42
|
+
\x1b[1mEXAMPLES\x1b[0m
|
|
43
|
+
$ \x1b[32mcursorflow init --example\x1b[0m
|
|
44
|
+
$ \x1b[32mcursorflow run _cursorflow/tasks/MyFeature/\x1b[0m
|
|
45
|
+
$ \x1b[32mcursorflow monitor latest\x1b[0m
|
|
46
|
+
$ \x1b[32mcursorflow signal lane-1 "Please use pnpm instead of npm"\x1b[0m
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
\x1b[1mDOCUMENTATION\x1b[0m
|
|
49
49
|
https://github.com/eungjin-cigro/cursorflow#readme
|
|
50
50
|
`);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
function printVersion(): void {
|
|
54
54
|
const pkg = require('../../package.json');
|
|
55
|
-
console.log(
|
|
55
|
+
console.log(`\x1b[1m\x1b[36mCursorFlow\x1b[0m v${pkg.version}`);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
async function main(): Promise<void> {
|
|
59
59
|
const args = process.argv.slice(2);
|
|
60
60
|
|
|
61
|
-
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
61
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
|
62
62
|
printHelp();
|
|
63
63
|
return;
|
|
64
64
|
}
|
package/src/cli/monitor.ts
CHANGED
|
@@ -44,6 +44,7 @@ class InteractiveMonitor {
|
|
|
44
44
|
private terminalScrollOffset: number = 0;
|
|
45
45
|
private lastTerminalTotalLines: number = 0;
|
|
46
46
|
private interventionInput: string = '';
|
|
47
|
+
private notification: { message: string; type: 'info' | 'error' | 'success'; time: number } | null = null;
|
|
47
48
|
|
|
48
49
|
constructor(runDir: string, interval: number) {
|
|
49
50
|
this.runDir = runDir;
|
|
@@ -155,6 +156,9 @@ class InteractiveMonitor {
|
|
|
155
156
|
this.terminalScrollOffset = 0;
|
|
156
157
|
this.render();
|
|
157
158
|
break;
|
|
159
|
+
case 'k':
|
|
160
|
+
this.killLane();
|
|
161
|
+
break;
|
|
158
162
|
case 'i':
|
|
159
163
|
const lane = this.lanes.find(l => l.name === this.selectedLaneName);
|
|
160
164
|
if (lane) {
|
|
@@ -304,10 +308,43 @@ class InteractiveMonitor {
|
|
|
304
308
|
this.render();
|
|
305
309
|
}
|
|
306
310
|
|
|
311
|
+
private killLane() {
|
|
312
|
+
if (!this.selectedLaneName) return;
|
|
313
|
+
const lane = this.lanes.find(l => l.name === this.selectedLaneName);
|
|
314
|
+
if (!lane) return;
|
|
315
|
+
|
|
316
|
+
const status = this.getLaneStatus(lane.path, lane.name);
|
|
317
|
+
if (status.pid && status.status === 'running') {
|
|
318
|
+
try {
|
|
319
|
+
process.kill(status.pid, 'SIGTERM');
|
|
320
|
+
this.showNotification(`Sent SIGTERM to PID ${status.pid}`, 'success');
|
|
321
|
+
} catch (e) {
|
|
322
|
+
this.showNotification(`Failed to kill PID ${status.pid}`, 'error');
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
this.showNotification(`No running process found for ${this.selectedLaneName}`, 'info');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private showNotification(message: string, type: 'info' | 'error' | 'success') {
|
|
330
|
+
this.notification = { message, type, time: Date.now() };
|
|
331
|
+
this.render();
|
|
332
|
+
}
|
|
333
|
+
|
|
307
334
|
private render() {
|
|
308
335
|
// Clear screen
|
|
309
336
|
process.stdout.write('\x1Bc');
|
|
310
337
|
|
|
338
|
+
// Clear old notifications
|
|
339
|
+
if (this.notification && Date.now() - this.notification.time > 3000) {
|
|
340
|
+
this.notification = null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (this.notification) {
|
|
344
|
+
const color = this.notification.type === 'error' ? '\x1b[31m' : this.notification.type === 'success' ? '\x1b[32m' : '\x1b[36m';
|
|
345
|
+
console.log(`${color}š ${this.notification.message}\x1b[0m\n`);
|
|
346
|
+
}
|
|
347
|
+
|
|
311
348
|
switch (this.view) {
|
|
312
349
|
case View.LIST:
|
|
313
350
|
this.renderList();
|
|
@@ -413,6 +450,7 @@ class InteractiveMonitor {
|
|
|
413
450
|
console.log('āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā\n');
|
|
414
451
|
|
|
415
452
|
process.stdout.write(` Status: ${this.getStatusIcon(status.status)} ${status.status}\n`);
|
|
453
|
+
process.stdout.write(` PID: ${status.pid || '-'}\n`);
|
|
416
454
|
process.stdout.write(` Progress: ${status.progress} (${status.currentTask}/${status.totalTasks} tasks)\n`);
|
|
417
455
|
process.stdout.write(` Time: ${this.formatDuration(status.duration)}\n`);
|
|
418
456
|
process.stdout.write(` Branch: ${status.pipelineBranch}\n`);
|
|
@@ -429,7 +467,7 @@ class InteractiveMonitor {
|
|
|
429
467
|
|
|
430
468
|
console.log('\nš¬ Conversation History (Select to see full details):');
|
|
431
469
|
console.log('ā'.repeat(80));
|
|
432
|
-
process.stdout.write(' [ā/ā] Browse | [ā/Enter] Full Msg | [I] Intervene | [T] Live Terminal | [Esc/ā] Back\n\n');
|
|
470
|
+
process.stdout.write(' [ā/ā] Browse | [ā/Enter] Full Msg | [I] Intervene | [K] Kill | [T] Live Terminal | [Esc/ā] Back\n\n');
|
|
433
471
|
|
|
434
472
|
if (this.currentLogs.length === 0) {
|
|
435
473
|
console.log(' (No messages yet)');
|
|
@@ -670,6 +708,7 @@ class InteractiveMonitor {
|
|
|
670
708
|
dependsOn,
|
|
671
709
|
duration,
|
|
672
710
|
error: state.error,
|
|
711
|
+
pid: state.pid
|
|
673
712
|
};
|
|
674
713
|
}
|
|
675
714
|
|
package/src/core/runner.ts
CHANGED
|
@@ -107,15 +107,79 @@ function parseJsonFromStdout(stdout: string): any {
|
|
|
107
107
|
return null;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/** Default timeout: 5 minutes */
|
|
111
|
+
const DEFAULT_TIMEOUT_MS = 300000;
|
|
112
|
+
|
|
113
|
+
/** Heartbeat interval: 30 seconds */
|
|
114
|
+
const HEARTBEAT_INTERVAL_MS = 30000;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validate task configuration
|
|
118
|
+
* @throws Error if validation fails
|
|
119
|
+
*/
|
|
120
|
+
export function validateTaskConfig(config: RunnerConfig): void {
|
|
121
|
+
if (!config.tasks || !Array.isArray(config.tasks)) {
|
|
122
|
+
throw new Error('Invalid config: "tasks" must be an array');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (config.tasks.length === 0) {
|
|
126
|
+
throw new Error('Invalid config: "tasks" array is empty');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (let i = 0; i < config.tasks.length; i++) {
|
|
130
|
+
const task = config.tasks[i];
|
|
131
|
+
const taskNum = i + 1;
|
|
132
|
+
|
|
133
|
+
if (!task) {
|
|
134
|
+
throw new Error(`Invalid config: Task ${taskNum} is null or undefined`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!task.name || typeof task.name !== 'string') {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Invalid config: Task ${taskNum} missing required "name" field.\n` +
|
|
140
|
+
` Found: ${JSON.stringify(task, null, 2).substring(0, 200)}...\n` +
|
|
141
|
+
` Expected: { "name": "task-name", "prompt": "..." }`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!task.prompt || typeof task.prompt !== 'string') {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Invalid config: Task "${task.name}" (${taskNum}) missing required "prompt" field`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Validate task name format (no spaces, special chars that could break branch names)
|
|
152
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(task.name)) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Invalid config: Task name "${task.name}" contains invalid characters.\n` +
|
|
155
|
+
` Task names must only contain: letters, numbers, underscore (_), hyphen (-)`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Validate timeout if provided
|
|
161
|
+
if (config.timeout !== undefined) {
|
|
162
|
+
if (typeof config.timeout !== 'number' || config.timeout <= 0) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Invalid config: "timeout" must be a positive number (milliseconds).\n` +
|
|
165
|
+
` Found: ${config.timeout}`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
110
171
|
/**
|
|
111
172
|
* Execute cursor-agent command with streaming and better error handling
|
|
112
173
|
*/
|
|
113
|
-
export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, signalDir }: {
|
|
174
|
+
export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, signalDir, timeout, enableIntervention }: {
|
|
114
175
|
workspaceDir: string;
|
|
115
176
|
chatId: string;
|
|
116
177
|
prompt: string;
|
|
117
178
|
model?: string;
|
|
118
179
|
signalDir?: string;
|
|
180
|
+
timeout?: number;
|
|
181
|
+
/** Enable stdin piping for intervention feature (may cause buffering issues on some systems) */
|
|
182
|
+
enableIntervention?: boolean;
|
|
119
183
|
}): Promise<AgentSendResult> {
|
|
120
184
|
const args = [
|
|
121
185
|
'--print',
|
|
@@ -126,17 +190,67 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
|
|
|
126
190
|
prompt,
|
|
127
191
|
];
|
|
128
192
|
|
|
129
|
-
|
|
193
|
+
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
194
|
+
logger.info(`Executing cursor-agent... (timeout: ${Math.round(timeoutMs / 1000)}s)`);
|
|
195
|
+
|
|
196
|
+
// Determine stdio mode based on intervention setting
|
|
197
|
+
// When intervention is enabled, we pipe stdin for message injection
|
|
198
|
+
// When disabled (default), we ignore stdin to avoid buffering issues
|
|
199
|
+
const stdinMode = enableIntervention ? 'pipe' : 'ignore';
|
|
200
|
+
|
|
201
|
+
if (enableIntervention) {
|
|
202
|
+
logger.info('Intervention mode enabled (stdin piped)');
|
|
203
|
+
}
|
|
130
204
|
|
|
131
205
|
return new Promise((resolve) => {
|
|
206
|
+
// Build environment, preserving user's NODE_OPTIONS but disabling problematic flags
|
|
207
|
+
const childEnv = { ...process.env };
|
|
208
|
+
|
|
209
|
+
// Only filter out specific problematic NODE_OPTIONS, don't clear entirely
|
|
210
|
+
if (childEnv.NODE_OPTIONS) {
|
|
211
|
+
// Remove flags that might interfere with cursor-agent
|
|
212
|
+
const filtered = childEnv.NODE_OPTIONS
|
|
213
|
+
.split(' ')
|
|
214
|
+
.filter(opt => !opt.includes('--inspect') && !opt.includes('--debug'))
|
|
215
|
+
.join(' ');
|
|
216
|
+
childEnv.NODE_OPTIONS = filtered;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Disable Python buffering in case cursor-agent uses Python
|
|
220
|
+
childEnv.PYTHONUNBUFFERED = '1';
|
|
221
|
+
|
|
132
222
|
const child = spawn('cursor-agent', args, {
|
|
133
|
-
stdio: [
|
|
134
|
-
env:
|
|
223
|
+
stdio: [stdinMode, 'pipe', 'pipe'],
|
|
224
|
+
env: childEnv,
|
|
135
225
|
});
|
|
136
226
|
|
|
227
|
+
// Save PID to state if possible
|
|
228
|
+
if (child.pid && signalDir) {
|
|
229
|
+
try {
|
|
230
|
+
const statePath = path.join(signalDir, 'state.json');
|
|
231
|
+
if (fs.existsSync(statePath)) {
|
|
232
|
+
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
233
|
+
state.pid = child.pid;
|
|
234
|
+
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
|
|
235
|
+
}
|
|
236
|
+
} catch (e) {
|
|
237
|
+
// Best effort
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
137
241
|
let fullStdout = '';
|
|
138
242
|
let fullStderr = '';
|
|
139
243
|
|
|
244
|
+
// Heartbeat logging to show progress
|
|
245
|
+
let lastHeartbeat = Date.now();
|
|
246
|
+
let bytesReceived = 0;
|
|
247
|
+
const heartbeatInterval = setInterval(() => {
|
|
248
|
+
const elapsed = Math.round((Date.now() - lastHeartbeat) / 1000);
|
|
249
|
+
const totalElapsed = Math.round((Date.now() - startTime) / 1000);
|
|
250
|
+
logger.info(`ā± Heartbeat: ${totalElapsed}s elapsed, ${bytesReceived} bytes received`);
|
|
251
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
252
|
+
const startTime = Date.now();
|
|
253
|
+
|
|
140
254
|
// Watch for "intervention.txt" signal file if any
|
|
141
255
|
const interventionPath = signalDir ? path.join(signalDir, 'intervention.txt') : null;
|
|
142
256
|
let interventionWatcher: fs.FSWatcher | null = null;
|
|
@@ -147,8 +261,13 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
|
|
|
147
261
|
try {
|
|
148
262
|
const message = fs.readFileSync(interventionPath, 'utf8').trim();
|
|
149
263
|
if (message) {
|
|
150
|
-
|
|
151
|
-
|
|
264
|
+
if (enableIntervention && child.stdin) {
|
|
265
|
+
logger.info(`Injecting intervention: ${message}`);
|
|
266
|
+
child.stdin.write(message + '\n');
|
|
267
|
+
} else {
|
|
268
|
+
logger.warn(`Intervention requested but stdin not available: ${message}`);
|
|
269
|
+
logger.warn('To enable intervention, set enableIntervention: true in config');
|
|
270
|
+
}
|
|
152
271
|
fs.unlinkSync(interventionPath); // Clear it
|
|
153
272
|
}
|
|
154
273
|
} catch (e) {
|
|
@@ -158,30 +277,38 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
|
|
|
158
277
|
});
|
|
159
278
|
}
|
|
160
279
|
|
|
161
|
-
child.stdout
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
280
|
+
if (child.stdout) {
|
|
281
|
+
child.stdout.on('data', (data) => {
|
|
282
|
+
const str = data.toString();
|
|
283
|
+
fullStdout += str;
|
|
284
|
+
bytesReceived += data.length;
|
|
285
|
+
// Also pipe to our own stdout so it goes to terminal.log
|
|
286
|
+
process.stdout.write(data);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
167
289
|
|
|
168
|
-
child.stderr
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
290
|
+
if (child.stderr) {
|
|
291
|
+
child.stderr.on('data', (data) => {
|
|
292
|
+
fullStderr += data.toString();
|
|
293
|
+
// Pipe to our own stderr so it goes to terminal.log
|
|
294
|
+
process.stderr.write(data);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
173
297
|
|
|
174
|
-
const
|
|
298
|
+
const timeoutHandle = setTimeout(() => {
|
|
299
|
+
clearInterval(heartbeatInterval);
|
|
175
300
|
child.kill();
|
|
301
|
+
const timeoutSec = Math.round(timeoutMs / 1000);
|
|
176
302
|
resolve({
|
|
177
303
|
ok: false,
|
|
178
304
|
exitCode: -1,
|
|
179
|
-
error:
|
|
305
|
+
error: `cursor-agent timed out after ${timeoutSec} seconds. The LLM request may be taking too long or there may be network issues.`,
|
|
180
306
|
});
|
|
181
|
-
},
|
|
307
|
+
}, timeoutMs);
|
|
182
308
|
|
|
183
309
|
child.on('close', (code) => {
|
|
184
|
-
clearTimeout(
|
|
310
|
+
clearTimeout(timeoutHandle);
|
|
311
|
+
clearInterval(heartbeatInterval);
|
|
185
312
|
if (interventionWatcher) interventionWatcher.close();
|
|
186
313
|
|
|
187
314
|
const json = parseJsonFromStdout(fullStdout);
|
|
@@ -214,7 +341,8 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
|
|
|
214
341
|
});
|
|
215
342
|
|
|
216
343
|
child.on('error', (err) => {
|
|
217
|
-
clearTimeout(
|
|
344
|
+
clearTimeout(timeoutHandle);
|
|
345
|
+
clearInterval(heartbeatInterval);
|
|
218
346
|
resolve({
|
|
219
347
|
ok: false,
|
|
220
348
|
exitCode: -1,
|
|
@@ -361,7 +489,9 @@ export async function runTask({
|
|
|
361
489
|
chatId,
|
|
362
490
|
prompt: prompt1,
|
|
363
491
|
model,
|
|
364
|
-
signalDir: runDir
|
|
492
|
+
signalDir: runDir,
|
|
493
|
+
timeout: config.timeout,
|
|
494
|
+
enableIntervention: config.enableIntervention,
|
|
365
495
|
});
|
|
366
496
|
|
|
367
497
|
appendLog(convoPath, createConversationEntry('assistant', r1.resultText || r1.error || 'No response', {
|
|
@@ -405,6 +535,17 @@ export async function runTask({
|
|
|
405
535
|
export async function runTasks(tasksFile: string, config: RunnerConfig, runDir: string, options: { startIndex?: number } = {}): Promise<TaskExecutionResult[]> {
|
|
406
536
|
const startIndex = options.startIndex || 0;
|
|
407
537
|
|
|
538
|
+
// Validate configuration before starting
|
|
539
|
+
logger.info('Validating task configuration...');
|
|
540
|
+
try {
|
|
541
|
+
validateTaskConfig(config);
|
|
542
|
+
logger.success('ā Configuration valid');
|
|
543
|
+
} catch (validationError: any) {
|
|
544
|
+
logger.error('ā Configuration validation failed');
|
|
545
|
+
logger.error(` ${validationError.message}`);
|
|
546
|
+
throw validationError;
|
|
547
|
+
}
|
|
548
|
+
|
|
408
549
|
// Ensure cursor-agent is installed
|
|
409
550
|
ensureCursorAgent();
|
|
410
551
|
|
package/src/utils/types.ts
CHANGED
|
@@ -50,6 +50,14 @@ export interface RunnerConfig {
|
|
|
50
50
|
reviewModel?: string;
|
|
51
51
|
maxReviewIterations?: number;
|
|
52
52
|
acceptanceCriteria?: string[];
|
|
53
|
+
/** Task execution timeout in milliseconds. Default: 300000 (5 minutes) */
|
|
54
|
+
timeout?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Enable intervention feature (stdin piping for message injection).
|
|
57
|
+
* Warning: May cause stdout buffering issues on some systems.
|
|
58
|
+
* Default: false
|
|
59
|
+
*/
|
|
60
|
+
enableIntervention?: boolean;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
export interface DependencyRequestPlan {
|
|
@@ -111,6 +119,7 @@ export interface LaneState {
|
|
|
111
119
|
updatedAt?: number;
|
|
112
120
|
tasksFile?: string; // Original tasks file path
|
|
113
121
|
dependsOn?: string[];
|
|
122
|
+
pid?: number;
|
|
114
123
|
}
|
|
115
124
|
|
|
116
125
|
export interface ConversationEntry {
|