@litmers/cursorflow-orchestrator 0.2.2 → 0.2.5

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.
Files changed (95) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +8 -11
  3. package/dist/cli/complete.d.ts +7 -0
  4. package/dist/cli/complete.js +304 -0
  5. package/dist/cli/complete.js.map +1 -0
  6. package/dist/cli/index.js +0 -6
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/cli/logs.js +51 -61
  9. package/dist/cli/logs.js.map +1 -1
  10. package/dist/cli/monitor.js +74 -46
  11. package/dist/cli/monitor.js.map +1 -1
  12. package/dist/cli/resume.js +2 -2
  13. package/dist/cli/resume.js.map +1 -1
  14. package/dist/cli/signal.js +33 -29
  15. package/dist/cli/signal.js.map +1 -1
  16. package/dist/core/auto-recovery.d.ts +2 -117
  17. package/dist/core/auto-recovery.js +4 -487
  18. package/dist/core/auto-recovery.js.map +1 -1
  19. package/dist/core/failure-policy.d.ts +0 -52
  20. package/dist/core/failure-policy.js +7 -174
  21. package/dist/core/failure-policy.js.map +1 -1
  22. package/dist/core/git-lifecycle-manager.js +2 -2
  23. package/dist/core/git-lifecycle-manager.js.map +1 -1
  24. package/dist/core/git-pipeline-coordinator.js +25 -25
  25. package/dist/core/git-pipeline-coordinator.js.map +1 -1
  26. package/dist/core/intervention.d.ts +0 -6
  27. package/dist/core/intervention.js +1 -17
  28. package/dist/core/intervention.js.map +1 -1
  29. package/dist/core/orchestrator.js +18 -10
  30. package/dist/core/orchestrator.js.map +1 -1
  31. package/dist/core/runner/agent.js +18 -15
  32. package/dist/core/runner/agent.js.map +1 -1
  33. package/dist/core/runner/pipeline.js +3 -3
  34. package/dist/core/runner/pipeline.js.map +1 -1
  35. package/dist/core/stall-detection.js +9 -7
  36. package/dist/core/stall-detection.js.map +1 -1
  37. package/dist/hooks/data-accessor.js +2 -2
  38. package/dist/hooks/data-accessor.js.map +1 -1
  39. package/dist/services/logging/buffer.d.ts +1 -2
  40. package/dist/services/logging/buffer.js +22 -63
  41. package/dist/services/logging/buffer.js.map +1 -1
  42. package/dist/services/logging/formatter.d.ts +4 -0
  43. package/dist/services/logging/formatter.js +201 -33
  44. package/dist/services/logging/formatter.js.map +1 -1
  45. package/dist/services/logging/paths.d.ts +0 -3
  46. package/dist/services/logging/paths.js +0 -3
  47. package/dist/services/logging/paths.js.map +1 -1
  48. package/dist/types/config.d.ts +1 -9
  49. package/dist/types/logging.d.ts +1 -1
  50. package/dist/utils/config.js +2 -6
  51. package/dist/utils/config.js.map +1 -1
  52. package/dist/utils/enhanced-logger.d.ts +17 -37
  53. package/dist/utils/enhanced-logger.js +237 -267
  54. package/dist/utils/enhanced-logger.js.map +1 -1
  55. package/dist/utils/logger.js +17 -4
  56. package/dist/utils/logger.js.map +1 -1
  57. package/dist/utils/repro-thinking-logs.js +4 -4
  58. package/dist/utils/repro-thinking-logs.js.map +1 -1
  59. package/package.json +3 -14
  60. package/scripts/monitor-lanes.sh +5 -5
  61. package/scripts/stream-logs.sh +1 -1
  62. package/scripts/test-log-parser.ts +8 -42
  63. package/src/cli/complete.ts +305 -0
  64. package/src/cli/index.ts +0 -6
  65. package/src/cli/logs.ts +46 -60
  66. package/src/cli/monitor.ts +82 -48
  67. package/src/cli/resume.ts +1 -1
  68. package/src/cli/signal.ts +38 -34
  69. package/src/core/auto-recovery.ts +13 -595
  70. package/src/core/failure-policy.ts +7 -228
  71. package/src/core/git-lifecycle-manager.ts +2 -2
  72. package/src/core/git-pipeline-coordinator.ts +25 -25
  73. package/src/core/intervention.ts +0 -18
  74. package/src/core/orchestrator.ts +20 -10
  75. package/src/core/runner/agent.ts +21 -16
  76. package/src/core/runner/pipeline.ts +3 -3
  77. package/src/core/stall-detection.ts +11 -9
  78. package/src/hooks/data-accessor.ts +2 -2
  79. package/src/services/logging/buffer.ts +20 -68
  80. package/src/services/logging/formatter.ts +199 -32
  81. package/src/services/logging/paths.ts +0 -3
  82. package/src/types/config.ts +1 -13
  83. package/src/types/logging.ts +2 -0
  84. package/src/utils/config.ts +2 -6
  85. package/src/utils/enhanced-logger.ts +239 -290
  86. package/src/utils/logger.ts +18 -3
  87. package/src/utils/repro-thinking-logs.ts +4 -4
  88. package/dist/cli/prepare.d.ts +0 -7
  89. package/dist/cli/prepare.js +0 -690
  90. package/dist/cli/prepare.js.map +0 -1
  91. package/dist/utils/log-formatter.d.ts +0 -26
  92. package/dist/utils/log-formatter.js +0 -274
  93. package/dist/utils/log-formatter.js.map +0 -1
  94. package/src/cli/prepare.ts +0 -777
  95. package/src/utils/log-formatter.ts +0 -287
package/src/cli/index.ts CHANGED
@@ -19,8 +19,6 @@ const COMMANDS: Record<string, CommandFn> = {
19
19
  new: require('./new'),
20
20
  add: require('./add'),
21
21
  config: require('./config'),
22
- // Legacy prepare command (deprecated)
23
- prepare: require('./prepare'),
24
22
  run: require('./run'),
25
23
  monitor: require('./monitor'),
26
24
  clean: require('./clean'),
@@ -32,7 +30,6 @@ const COMMANDS: Record<string, CommandFn> = {
32
30
  tasks: require('./tasks'),
33
31
  stop: require('./stop'),
34
32
  setup: require('./setup-commands').main,
35
- 'setup-commands': require('./setup-commands').main,
36
33
  };
37
34
 
38
35
  function printHelp(): void {
@@ -65,9 +62,6 @@ function printHelp(): void {
65
62
  \x1b[33msignal\x1b[0m <lane> <msg> Directly intervene in a running lane
66
63
  \x1b[33mmodels\x1b[0m [options] List available AI models
67
64
 
68
- \x1b[1mLEGACY\x1b[0m
69
- \x1b[33mprepare\x1b[0m <feature> [opts] (deprecated) Use 'new' + 'add' instead
70
-
71
65
  \x1b[1mQUICK START\x1b[0m
72
66
  $ \x1b[32mcursorflow new MyFeature --lanes "backend,frontend"\x1b[0m
73
67
  $ \x1b[32mcursorflow add MyFeature backend --task "name=impl|model=sonnet-4.5|prompt=API 구현"\x1b[0m
package/src/cli/logs.ts CHANGED
@@ -10,10 +10,9 @@ import { safeJoin } from '../utils/path';
10
10
  import {
11
11
  readJsonLog,
12
12
  exportLogs,
13
- stripAnsi,
14
13
  JsonLogEntry
15
14
  } from '../utils/enhanced-logger';
16
- import { formatPotentialJsonMessage } from '../utils/log-formatter';
15
+ import { formatMessageForConsole, formatPotentialJsonMessage, stripAnsi } from '../services/logging/formatter';
17
16
  import { MAIN_LOG_FILENAME } from '../utils/log-constants';
18
17
  import { startLogViewer } from '../ui/log-viewer';
19
18
 
@@ -152,32 +151,33 @@ function listLanes(runDir: string): string[] {
152
151
  }
153
152
 
154
153
  /**
155
- * Read and display text logs
154
+ * Read and display text logs (converted from JSONL)
156
155
  */
157
156
  function displayTextLogs(
158
157
  laneDir: string,
159
158
  options: LogsOptions
160
159
  ): void {
161
- let logFile: string;
162
- const readableLog = safeJoin(laneDir, 'terminal-readable.log');
163
- const rawLog = safeJoin(laneDir, 'terminal-raw.log');
164
-
165
- if (options.raw) {
166
- logFile = rawLog;
167
- } else {
168
- // Default to readable log (clean option also uses readable now)
169
- logFile = readableLog;
170
- }
160
+ const logFile = safeJoin(laneDir, 'terminal.jsonl');
171
161
 
172
162
  if (!fs.existsSync(logFile)) {
173
163
  console.log('No log file found.');
174
164
  return;
175
165
  }
176
166
 
177
- let content = fs.readFileSync(logFile, 'utf8');
178
- let lines = content.split('\n');
167
+ const entries = readJsonLog(logFile);
168
+ let lines = entries.map(entry => {
169
+ const ts = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false });
170
+ const level = entry.level || 'info';
171
+ const content = entry.content || entry.message || '';
172
+
173
+ if (options.raw) {
174
+ // In "raw" mode for JSONL, we show a basic text representation
175
+ return `[${ts}] [${level.toUpperCase()}] ${content}`;
176
+ }
177
+ return `[${ts}] [${level.toUpperCase()}] ${stripAnsi(content)}`;
178
+ });
179
179
 
180
- // Apply filter (case-insensitive string match to avoid ReDoS)
180
+ // Apply filter
181
181
  if (options.filter) {
182
182
  const filterLower = options.filter.toLowerCase();
183
183
  lines = lines.filter(line => line.toLowerCase().includes(filterLower));
@@ -188,11 +188,6 @@ function displayTextLogs(
188
188
  lines = lines.slice(-options.tail);
189
189
  }
190
190
 
191
- // Clean ANSI if needed (for clean mode or default fallback)
192
- if (!options.raw) {
193
- lines = lines.map(line => stripAnsi(line));
194
- }
195
-
196
191
  console.log(lines.join('\n'));
197
192
  }
198
193
 
@@ -708,16 +703,7 @@ function escapeHtml(text: string): string {
708
703
  * Follow logs in real-time
709
704
  */
710
705
  function followLogs(laneDir: string, options: LogsOptions): void {
711
- let logFile: string;
712
- const readableLog = safeJoin(laneDir, 'terminal-readable.log');
713
- const rawLog = safeJoin(laneDir, 'terminal-raw.log');
714
-
715
- if (options.raw) {
716
- logFile = rawLog;
717
- } else {
718
- // Default to readable log
719
- logFile = readableLog;
720
- }
706
+ const logFile = safeJoin(laneDir, 'terminal.jsonl');
721
707
 
722
708
  if (!fs.existsSync(logFile)) {
723
709
  console.log('Waiting for log file...');
@@ -725,17 +711,14 @@ function followLogs(laneDir: string, options: LogsOptions): void {
725
711
 
726
712
  let lastSize = 0;
727
713
  try {
728
- // Use statSync directly to avoid TOCTOU race condition
729
714
  lastSize = fs.statSync(logFile).size;
730
715
  } catch {
731
- // File doesn't exist yet or other error - start from 0
732
716
  lastSize = 0;
733
717
  }
734
718
 
735
719
  console.log(`${logger.COLORS.cyan}Following ${logFile}... (Ctrl+C to stop)${logger.COLORS.reset}\n`);
736
720
 
737
721
  const checkInterval = setInterval(() => {
738
- // Use fstat on open fd to avoid TOCTOU race condition
739
722
  let fd: number | null = null;
740
723
  try {
741
724
  fd = fs.openSync(logFile, 'r');
@@ -744,28 +727,37 @@ function followLogs(laneDir: string, options: LogsOptions): void {
744
727
  const buffer = Buffer.alloc(stats.size - lastSize);
745
728
  fs.readSync(fd, buffer, 0, buffer.length, lastSize);
746
729
 
747
- let content = buffer.toString();
748
-
749
- // Apply filter (case-insensitive string match to avoid ReDoS)
750
- if (options.filter) {
751
- const filterLower = options.filter.toLowerCase();
752
- const lines = content.split('\n');
753
- content = lines.filter(line => line.toLowerCase().includes(filterLower)).join('\n');
754
- }
755
-
756
- // Clean ANSI if needed (unless raw mode)
757
- if (!options.raw) {
758
- content = stripAnsi(content);
759
- }
730
+ const content = buffer.toString();
731
+ const lines = content.split('\n').filter(l => l.trim());
760
732
 
761
- if (content.trim()) {
762
- process.stdout.write(content);
733
+ for (const line of lines) {
734
+ try {
735
+ const entry = JSON.parse(line);
736
+ const ts = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false });
737
+ const level = entry.level || 'info';
738
+ const levelColor = getLevelColor(level);
739
+ const message = entry.content || entry.message || '';
740
+
741
+ // Apply level filter
742
+ if (options.level && level !== options.level) continue;
743
+
744
+ // Apply filter
745
+ if (options.filter) {
746
+ const filterLower = options.filter.toLowerCase();
747
+ if (!message.toLowerCase().includes(filterLower)) continue;
748
+ }
749
+
750
+ const displayMsg = options.raw ? message : stripAnsi(message);
751
+ console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${levelColor}[${level.toUpperCase().padEnd(6)}]${logger.COLORS.reset} ${displayMsg}`);
752
+ } catch {
753
+ // Skip invalid JSON
754
+ }
763
755
  }
764
756
 
765
757
  lastSize = stats.size;
766
758
  }
767
759
  } catch {
768
- // Ignore errors (file might be rotating)
760
+ // Ignore errors
769
761
  } finally {
770
762
  if (fd !== null) {
771
763
  try { fs.closeSync(fd); } catch { /* ignore */ }
@@ -860,19 +852,13 @@ function displaySummary(runDir: string): void {
860
852
 
861
853
  for (const lane of lanes) {
862
854
  const laneDir = safeJoin(runDir, 'lanes', lane);
863
- const rawLog = safeJoin(laneDir, 'terminal-raw.log');
864
- const readableLog = safeJoin(laneDir, 'terminal-readable.log');
855
+ const jsonlLog = safeJoin(laneDir, 'terminal.jsonl');
865
856
 
866
857
  console.log(` ${logger.COLORS.green}📁 ${lane}${logger.COLORS.reset}`);
867
858
 
868
- if (fs.existsSync(readableLog)) {
869
- const stats = fs.statSync(readableLog);
870
- console.log(` └─ terminal-readable.log ${formatSize(stats.size)} ${logger.COLORS.yellow}(default)${logger.COLORS.reset}`);
871
- }
872
-
873
- if (fs.existsSync(rawLog)) {
874
- const stats = fs.statSync(rawLog);
875
- console.log(` └─ terminal-raw.log ${formatSize(stats.size)}`);
859
+ if (fs.existsSync(jsonlLog)) {
860
+ const stats = fs.statSync(jsonlLog);
861
+ console.log(` └─ terminal.jsonl ${formatSize(stats.size)} ${logger.COLORS.yellow}(unified)${logger.COLORS.reset}`);
876
862
  }
877
863
 
878
864
  console.log('');
@@ -19,6 +19,7 @@ import { safeJoin } from '../utils/path';
19
19
  import { getLaneProcessStatus, getFlowSummary, LaneProcessStatus } from '../services/process';
20
20
  import { LogBufferService, BufferedLogEntry } from '../services/logging/buffer';
21
21
  import { formatReadableEntry, stripAnsi } from '../services/logging/formatter';
22
+ import { createInterventionRequest, InterventionType, wrapUserIntervention } from '../core/intervention';
22
23
 
23
24
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
24
25
  // UI Constants
@@ -671,6 +672,7 @@ class InteractiveMonitor {
671
672
  private getLaneActions(lane: LaneInfo): ActionItem[] {
672
673
  const status = this.getLaneStatus(lane.path, lane.name);
673
674
  const isRunning = status.status === 'running';
675
+ const isCompleted = status.status === 'completed' || status.status === 'success';
674
676
 
675
677
  return [
676
678
  {
@@ -689,6 +691,14 @@ class InteractiveMonitor {
689
691
  disabled: !isRunning,
690
692
  disabledReason: 'Lane not running',
691
693
  },
694
+ {
695
+ id: 'resume',
696
+ label: 'Resume Lane',
697
+ icon: '▶️',
698
+ action: () => this.resumeLane(lane),
699
+ disabled: isRunning || isCompleted,
700
+ disabledReason: isRunning ? 'Lane already running' : 'Lane already completed',
701
+ },
692
702
  {
693
703
  id: 'stop',
694
704
  label: 'Stop Lane',
@@ -715,6 +725,8 @@ class InteractiveMonitor {
715
725
 
716
726
  private getFlowActions(flow: FlowInfo): ActionItem[] {
717
727
  const isCurrent = flow.runDir === this.runDir;
728
+ const isAlive = flow.isAlive;
729
+ const isCompleted = flow.summary.completed === flow.summary.total && flow.summary.total > 0;
718
730
 
719
731
  return [
720
732
  {
@@ -725,6 +737,14 @@ class InteractiveMonitor {
725
737
  disabled: isCurrent,
726
738
  disabledReason: 'Already viewing this flow',
727
739
  },
740
+ {
741
+ id: 'resume',
742
+ label: 'Resume Flow',
743
+ icon: '▶️',
744
+ action: () => this.resumeFlow(flow),
745
+ disabled: isAlive || isCompleted,
746
+ disabledReason: isAlive ? 'Flow is already running' : 'Flow is already completed',
747
+ },
728
748
  {
729
749
  id: 'delete',
730
750
  label: 'Delete Flow',
@@ -805,9 +825,24 @@ class InteractiveMonitor {
805
825
  if (!lane || !message.trim()) return;
806
826
 
807
827
  try {
808
- const interventionPath = safeJoin(lane.path, 'intervention.txt');
809
- fs.writeFileSync(interventionPath, message, 'utf8');
828
+ // Create pending-intervention.json for the system
829
+ createInterventionRequest(lane.path, {
830
+ type: InterventionType.USER_MESSAGE,
831
+ message: wrapUserIntervention(message),
832
+ source: 'user',
833
+ priority: 10
834
+ });
810
835
 
836
+ // Kill the process if it's running - this triggers the restart in orchestrator
837
+ const status = this.laneProcessStatuses.get(lane.name);
838
+ if (status && status.pid && status.actualStatus === 'running') {
839
+ try {
840
+ process.kill(status.pid, 'SIGTERM');
841
+ } catch {
842
+ // Ignore kill errors
843
+ }
844
+ }
845
+
811
846
  const convoPath = safeJoin(lane.path, 'conversation.jsonl');
812
847
  const entry = {
813
848
  timestamp: new Date().toISOString(),
@@ -858,6 +893,45 @@ class InteractiveMonitor {
858
893
  this.render();
859
894
  }
860
895
 
896
+ private resumeFlow(flow: FlowInfo) {
897
+ this.runResumeCommand(['--all', '--run-dir', flow.runDir]);
898
+ }
899
+
900
+ private resumeLane(lane: LaneInfo) {
901
+ this.runResumeCommand([lane.name, '--run-dir', this.runDir]);
902
+ }
903
+
904
+ private runResumeCommand(args: string[]) {
905
+ try {
906
+ const { spawn } = require('child_process');
907
+
908
+ // Determine the script to run
909
+ // In production, it's dist/cli/index.js. In dev, it's src/cli/index.ts.
910
+ let entryPoint = path.resolve(__dirname, 'index.js');
911
+ if (!fs.existsSync(entryPoint)) {
912
+ entryPoint = path.resolve(__dirname, 'index.ts');
913
+ }
914
+
915
+ const spawnArgs = [entryPoint, 'resume', ...args, '--skip-doctor'];
916
+
917
+ // If it's a .ts file, we need ts-node or similar (assuming it's available)
918
+ const nodeArgs = entryPoint.endsWith('.ts') ? ['-r', 'ts-node/register'] : [];
919
+
920
+ const child = spawn(process.execPath, [...nodeArgs, ...spawnArgs], {
921
+ detached: true,
922
+ stdio: 'ignore',
923
+ env: { ...process.env, NODE_OPTIONS: '' }
924
+ });
925
+
926
+ child.unref();
927
+
928
+ const target = args[0] === '--all' ? 'flow' : `lane ${args[0]}`;
929
+ this.showNotification(`Resume started for ${target}`, 'success');
930
+ } catch (error: any) {
931
+ this.showNotification(`Failed to spawn resume: ${error.message}`, 'error');
932
+ }
933
+ }
934
+
861
935
  private switchToFlow(flow: FlowInfo) {
862
936
  this.runDir = flow.runDir;
863
937
 
@@ -1336,55 +1410,15 @@ class InteractiveMonitor {
1336
1410
  }
1337
1411
 
1338
1412
  private getTerminalLines(lanePath: string, maxLines: number): string[] {
1339
- const { dim, reset, cyan, green, yellow, red, gray } = UI.COLORS;
1340
-
1341
- // Choose log source based on format setting
1342
- if (this.state.readableFormat) {
1343
- // Try JSONL first for structured readable format
1344
- const jsonlPath = safeJoin(lanePath, 'terminal.jsonl');
1345
- if (fs.existsSync(jsonlPath)) {
1346
- return this.getJsonlLogLines(jsonlPath, maxLines);
1347
- }
1348
- }
1413
+ const { dim, reset } = UI.COLORS;
1349
1414
 
1350
- // Fallback to raw terminal log
1351
- const logPath = safeJoin(lanePath, 'terminal-readable.log');
1352
- if (!fs.existsSync(logPath)) {
1353
- return [`${dim}(No output yet)${reset}`];
1415
+ // Use terminal.jsonl as the only source for structured readable format
1416
+ const jsonlPath = safeJoin(lanePath, 'terminal.jsonl');
1417
+ if (fs.existsSync(jsonlPath)) {
1418
+ return this.getJsonlLogLines(jsonlPath, maxLines);
1354
1419
  }
1355
1420
 
1356
- try {
1357
- const content = fs.readFileSync(logPath, 'utf8');
1358
- const allLines = content.split('\n');
1359
- const totalLines = allLines.length;
1360
-
1361
- // Calculate visible range (from end, accounting for scroll offset)
1362
- const end = Math.max(0, totalLines - this.state.terminalScrollOffset);
1363
- const start = Math.max(0, end - maxLines);
1364
- const visibleLines = allLines.slice(start, end);
1365
-
1366
- // Format lines with syntax highlighting
1367
- return visibleLines.map(line => {
1368
- if (line.includes('[HUMAN INTERVENTION]') || line.includes('Injecting intervention:')) {
1369
- return `${yellow}${line}${reset}`;
1370
- }
1371
- if (line.includes('=== Task:') || line.includes('Starting task:')) {
1372
- return `${green}${line}${reset}`;
1373
- }
1374
- if (line.includes('Executing cursor-agent') || line.includes('cursor-agent-v')) {
1375
- return `${cyan}${line}${reset}`;
1376
- }
1377
- if (line.toLowerCase().includes('error') || line.toLowerCase().includes('failed')) {
1378
- return `${red}${line}${reset}`;
1379
- }
1380
- if (line.toLowerCase().includes('success') || line.toLowerCase().includes('completed')) {
1381
- return `${green}${line}${reset}`;
1382
- }
1383
- return line;
1384
- });
1385
- } catch {
1386
- return [`${dim}(Error reading log)${reset}`];
1387
- }
1421
+ return [`${dim}(No output yet)${reset}`];
1388
1422
  }
1389
1423
 
1390
1424
  /**
package/src/cli/resume.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  createLogManager,
18
18
  ParsedMessage
19
19
  } from '../utils/enhanced-logger';
20
- import { formatMessageForConsole } from '../utils/log-formatter';
20
+ import { formatMessageForConsole } from '../services/logging/formatter';
21
21
  import { MAIN_LOG_FILENAME } from '../utils/log-constants';
22
22
 
23
23
  interface ResumeOptions {
package/src/cli/signal.ts CHANGED
@@ -19,6 +19,9 @@ import {
19
19
  executeUserIntervention,
20
20
  isProcessAlive,
21
21
  InterventionResult,
22
+ createInterventionRequest,
23
+ InterventionType,
24
+ wrapUserIntervention,
22
25
  } from '../core/intervention';
23
26
 
24
27
  interface SignalOptions {
@@ -26,7 +29,6 @@ interface SignalOptions {
26
29
  message: string | null;
27
30
  timeout: number | null;
28
31
  runDir: string | null;
29
- force: boolean; // 프로세스 종료 없이 대기 모드로 전송
30
32
  help: boolean;
31
33
  }
32
34
 
@@ -35,8 +37,12 @@ function printHelp(): void {
35
37
  Usage: cursorflow signal <lane> "<message>" [options]
36
38
  cursorflow signal <lane> --timeout <ms>
37
39
 
38
- Directly intervene in a running lane. The agent will be interrupted immediately
39
- and resume with your intervention message.
40
+ Send an intervention message to a lane. For running lanes, the agent will be
41
+ interrupted immediately and resume with your message. For pending/waiting/failed
42
+ lanes, the message will be applied when the lane starts or resumes.
43
+
44
+ Note: Completed lanes cannot receive signals.
45
+ To re-run a completed lane, start a new run with: cursorflow run
40
46
 
41
47
  Arguments:
42
48
  <lane> Lane name to signal
@@ -45,15 +51,12 @@ Arguments:
45
51
  Options:
46
52
  --timeout <ms> Update execution timeout (in milliseconds)
47
53
  --run-dir <path> Use a specific run directory (default: latest)
48
- --force Send signal without interrupting current process
49
- (message will be picked up on next task)
50
54
  --help, -h Show help
51
55
 
52
56
  Examples:
53
57
  cursorflow signal lane-1 "Please focus on error handling first"
54
58
  cursorflow signal lane-2 "Skip the optional tasks and finish"
55
59
  cursorflow signal lane-1 --timeout 600000 # Set 10 minute timeout
56
- cursorflow signal lane-1 "Continue" --force # Don't interrupt, wait for next turn
57
60
  `);
58
61
  }
59
62
 
@@ -61,15 +64,24 @@ function parseArgs(args: string[]): SignalOptions {
61
64
  const runDirIdx = args.indexOf('--run-dir');
62
65
  const timeoutIdx = args.indexOf('--timeout');
63
66
 
67
+ // Collect indices of option values to exclude from nonOptions
68
+ const optionValueIndices = new Set<number>();
69
+ if (runDirIdx >= 0 && runDirIdx + 1 < args.length) {
70
+ optionValueIndices.add(runDirIdx + 1);
71
+ }
72
+ if (timeoutIdx >= 0 && timeoutIdx + 1 < args.length) {
73
+ optionValueIndices.add(timeoutIdx + 1);
74
+ }
75
+
64
76
  // First non-option is lane, second (or rest joined) is message
65
- const nonOptions = args.filter(a => !a.startsWith('--'));
77
+ // Exclude option flags and their values
78
+ const nonOptions = args.filter((a, i) => !a.startsWith('--') && !optionValueIndices.has(i));
66
79
 
67
80
  return {
68
81
  lane: nonOptions[0] || null,
69
82
  message: nonOptions.slice(1).join(' ') || null,
70
83
  timeout: timeoutIdx >= 0 ? parseInt(args[timeoutIdx + 1] || '0') || null : null,
71
84
  runDir: runDirIdx >= 0 ? args[runDirIdx + 1] || null : null,
72
- force: args.includes('--force'),
73
85
  help: args.includes('--help') || args.includes('-h'),
74
86
  };
75
87
  }
@@ -108,18 +120,15 @@ function getLaneStatus(laneDir: string): { state: LaneState | null; isRunning: b
108
120
  }
109
121
 
110
122
  /**
111
- * 기존 방식으로 intervention.txt만 작성 (--force 옵션용)
123
+ * 개입 요청 파일 작성 (비실행 중인 lane용)
112
124
  */
113
- function sendLegacyIntervention(laneDir: string, message: string): void {
114
- const interventionPath = safeJoin(laneDir, 'intervention.txt');
115
- const convoPath = safeJoin(laneDir, 'conversation.jsonl');
116
-
117
- fs.writeFileSync(interventionPath, message);
118
-
119
- const entry = createConversationEntry('intervention', `[HUMAN INTERVENTION]: ${message}`, {
120
- task: 'DIRECT_SIGNAL'
125
+ function sendInterventionRequest(laneDir: string, message: string): void {
126
+ createInterventionRequest(laneDir, {
127
+ type: InterventionType.USER_MESSAGE,
128
+ message: wrapUserIntervention(message),
129
+ source: 'user',
130
+ priority: 10
121
131
  });
122
- appendLog(convoPath, entry);
123
132
  }
124
133
 
125
134
  async function signal(args: string[]): Promise<void> {
@@ -167,30 +176,25 @@ async function signal(args: string[]): Promise<void> {
167
176
  logger.info(`📨 Sending intervention to lane: ${options.lane}`);
168
177
  logger.info(` Message: "${options.message.substring(0, 50)}${options.message.length > 50 ? '...' : ''}"`);
169
178
 
179
+ // Completed 레인은 signal 거부 (브랜치 충돌 방지)
180
+ if (state?.status === 'completed') {
181
+ logger.error(`❌ Cannot signal a completed lane.`);
182
+ logger.info(' To re-run this lane, start a new run with: cursorflow run');
183
+ throw new Error('Lane is already completed');
184
+ }
185
+
170
186
  // Log to conversation for history
171
187
  const entry = createConversationEntry('intervention', `[HUMAN INTERVENTION]: ${options.message}`, {
172
188
  task: 'DIRECT_SIGNAL'
173
189
  });
174
190
  appendLog(convoPath, entry);
175
191
 
176
- // --force: 기존 방식 (프로세스 중단 없이 파일만 작성)
177
- if (options.force) {
178
- sendLegacyIntervention(laneDir, options.message);
179
- logger.success('✅ Signal queued (--force mode). Message will be applied on next task.');
180
- return;
181
- }
182
-
183
- // Lane이 실행 중이 아닌 경우
192
+ // Lane이 실행 중이 아닌 경우 (pending/waiting/failed/paused)
184
193
  if (!isRunning) {
185
- if (state?.status === 'completed') {
186
- logger.warn(`⚠ Lane ${options.lane} is already completed.`);
187
- return;
188
- }
189
-
190
- // 실행 중이 아니면 다음 resume 시 적용되도록 파일만 작성
191
- sendLegacyIntervention(laneDir, options.message);
194
+ // 실행 중이 아니면 다음 시작/resume 시 적용되도록 파일만 작성
195
+ sendInterventionRequest(laneDir, options.message);
192
196
  logger.info(`ℹ Lane ${options.lane} is not currently running (status: ${state?.status || 'unknown'}).`);
193
- logger.success('✅ Signal queued. Message will be applied when lane resumes.');
197
+ logger.success('✅ Signal queued. Message will be applied when lane starts or resumes.');
194
198
  return;
195
199
  }
196
200