@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/src/cli/clean.ts CHANGED
@@ -1,36 +1,156 @@
1
1
  /**
2
- * CursorFlow clean command (stub)
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[0], // branches | worktrees | logs | all
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
- logger.info('This command will be fully implemented in the next phase');
29
- logger.info(`Clean type: ${options.type}`);
30
- logger.info(`Dry run: ${options.dryRun}`);
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
- logger.warn('\nāš ļø Implementation pending');
33
- logger.info('This will clean branches, worktrees, and logs');
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
- CursorFlow - Git worktree-based parallel AI agent orchestration
23
+ \x1b[1m\x1b[36mCursorFlow\x1b[0m - Git worktree-based parallel AI agent orchestration
24
24
 
25
- Usage: cursorflow <command> [options]
25
+ \x1b[1mUSAGE\x1b[0m
26
+ $ \x1b[32mcursorflow\x1b[0m <command> [options]
26
27
 
27
- Commands:
28
- init [options] Initialize CursorFlow in project
29
- run <tasks-dir> [options] Run orchestration
30
- monitor [run-dir] [options] Monitor lane execution
31
- clean <type> [options] Clean branches/worktrees/logs
32
- resume <lane> [options] Resume interrupted lane
33
- doctor [options] Check environment and preflight
34
- signal <lane> <msg> Directly intervene in a running lane
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
- Global Options:
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
- Examples:
42
- cursorflow init --example
43
- cursorflow run _cursorflow/tasks/MyFeature/
44
- cursorflow monitor --watch
45
- cursorflow clean branches --all
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
- Documentation:
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(`CursorFlow v${pkg.version}`);
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
  }
@@ -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
 
@@ -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
- logger.info('Executing cursor-agent...');
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: ['pipe', 'pipe', 'pipe'], // Enable stdin piping
134
- env: process.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
- logger.info(`Injecting intervention: ${message}`);
151
- child.stdin.write(message + '\n');
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.on('data', (data) => {
162
- const str = data.toString();
163
- fullStdout += str;
164
- // Also pipe to our own stdout so it goes to terminal.log
165
- process.stdout.write(data);
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.on('data', (data) => {
169
- fullStderr += data.toString();
170
- // Pipe to our own stderr so it goes to terminal.log
171
- process.stderr.write(data);
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 timeout = setTimeout(() => {
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: 'cursor-agent timed out after 5 minutes. The LLM request may be taking too long or there may be network issues.',
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
- }, 300000);
307
+ }, timeoutMs);
182
308
 
183
309
  child.on('close', (code) => {
184
- clearTimeout(timeout);
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(timeout);
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
 
@@ -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 {