@litmers/cursorflow-orchestrator 0.1.5 → 0.1.8

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 (45) hide show
  1. package/CHANGELOG.md +15 -6
  2. package/README.md +33 -2
  3. package/commands/cursorflow-doctor.md +24 -0
  4. package/commands/cursorflow-signal.md +19 -0
  5. package/dist/cli/doctor.d.ts +15 -0
  6. package/dist/cli/doctor.js +139 -0
  7. package/dist/cli/doctor.js.map +1 -0
  8. package/dist/cli/index.js +5 -0
  9. package/dist/cli/index.js.map +1 -1
  10. package/dist/cli/monitor.d.ts +1 -1
  11. package/dist/cli/monitor.js +640 -145
  12. package/dist/cli/monitor.js.map +1 -1
  13. package/dist/cli/resume.d.ts +1 -1
  14. package/dist/cli/resume.js +80 -10
  15. package/dist/cli/resume.js.map +1 -1
  16. package/dist/cli/run.js +60 -5
  17. package/dist/cli/run.js.map +1 -1
  18. package/dist/cli/setup-commands.d.ts +4 -0
  19. package/dist/cli/setup-commands.js +16 -0
  20. package/dist/cli/setup-commands.js.map +1 -1
  21. package/dist/cli/signal.d.ts +7 -0
  22. package/dist/cli/signal.js +99 -0
  23. package/dist/cli/signal.js.map +1 -0
  24. package/dist/core/orchestrator.d.ts +4 -2
  25. package/dist/core/orchestrator.js +92 -23
  26. package/dist/core/orchestrator.js.map +1 -1
  27. package/dist/core/runner.d.ts +9 -3
  28. package/dist/core/runner.js +182 -88
  29. package/dist/core/runner.js.map +1 -1
  30. package/dist/utils/doctor.d.ts +63 -0
  31. package/dist/utils/doctor.js +280 -0
  32. package/dist/utils/doctor.js.map +1 -0
  33. package/dist/utils/types.d.ts +3 -0
  34. package/package.json +1 -1
  35. package/src/cli/doctor.ts +127 -0
  36. package/src/cli/index.ts +5 -0
  37. package/src/cli/monitor.ts +693 -185
  38. package/src/cli/resume.ts +94 -12
  39. package/src/cli/run.ts +63 -7
  40. package/src/cli/setup-commands.ts +19 -0
  41. package/src/cli/signal.ts +89 -0
  42. package/src/core/orchestrator.ts +102 -27
  43. package/src/core/runner.ts +203 -99
  44. package/src/utils/doctor.ts +312 -0
  45. package/src/utils/types.ts +3 -0
@@ -1,229 +1,737 @@
1
1
  /**
2
- * CursorFlow monitor command
2
+ * CursorFlow interactive monitor command
3
3
  */
4
4
 
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
+ import * as readline from 'readline';
7
8
  import * as logger from '../utils/logger';
8
- import { loadState } from '../utils/state';
9
- import { LaneState } from '../utils/types';
9
+ import { loadState, readLog } from '../utils/state';
10
+ import { LaneState, ConversationEntry } from '../utils/types';
10
11
  import { loadConfig } from '../utils/config';
11
12
 
13
+ interface LaneWithDeps {
14
+ name: string;
15
+ path: string;
16
+ dependsOn: string[];
17
+ }
18
+
12
19
  interface MonitorOptions {
13
20
  runDir?: string;
14
- watch: boolean;
15
21
  interval: number;
16
22
  }
17
23
 
18
- function parseArgs(args: string[]): MonitorOptions {
19
- const watch = args.includes('--watch');
20
- const intervalIdx = args.indexOf('--interval');
21
- const interval = intervalIdx >= 0 ? parseInt(args[intervalIdx + 1] || '2') || 2 : 2;
22
-
23
- // Find run directory (first non-option argument)
24
- const runDir = args.find(arg => !arg.startsWith('--') && args.indexOf(arg) !== intervalIdx + 1);
25
-
26
- return {
27
- runDir,
28
- watch,
29
- interval,
30
- };
24
+ enum View {
25
+ LIST,
26
+ LANE_DETAIL,
27
+ MESSAGE_DETAIL,
28
+ FLOW,
29
+ TERMINAL,
30
+ INTERVENE
31
31
  }
32
32
 
33
- /**
34
- * Find the latest run directory
35
- */
36
- function findLatestRunDir(logsDir: string): string | null {
37
- const runsDir = path.join(logsDir, 'runs');
38
-
39
- if (!fs.existsSync(runsDir)) {
40
- return null;
33
+ class InteractiveMonitor {
34
+ private runDir: string;
35
+ private interval: number;
36
+ private view: View = View.LIST;
37
+ private selectedLaneIndex: number = 0;
38
+ private selectedMessageIndex: number = 0;
39
+ private selectedLaneName: string | null = null;
40
+ private lanes: LaneWithDeps[] = [];
41
+ private currentLogs: ConversationEntry[] = [];
42
+ private timer: NodeJS.Timeout | null = null;
43
+ private scrollOffset: number = 0;
44
+ private terminalScrollOffset: number = 0;
45
+ private lastTerminalTotalLines: number = 0;
46
+ private interventionInput: string = '';
47
+
48
+ constructor(runDir: string, interval: number) {
49
+ this.runDir = runDir;
50
+ this.interval = interval;
41
51
  }
42
-
43
- const runs = fs.readdirSync(runsDir)
44
- .filter(d => d.startsWith('run-'))
45
- .map(d => ({
46
- name: d,
47
- path: path.join(runsDir, d),
48
- mtime: fs.statSync(path.join(runsDir, d)).mtime.getTime(),
49
- }))
50
- .sort((a, b) => b.mtime - a.mtime);
51
-
52
- return runs.length > 0 ? runs[0]!.path : null;
53
- }
54
52
 
55
- /**
56
- * List all lanes in a run directory
57
- */
58
- function listLanes(runDir: string): { name: string; path: string }[] {
59
- const lanesDir = path.join(runDir, 'lanes');
60
-
61
- if (!fs.existsSync(lanesDir)) {
62
- return [];
53
+ public async start() {
54
+ this.setupTerminal();
55
+ this.refresh();
56
+ this.timer = setInterval(() => this.refresh(), this.interval * 1000);
63
57
  }
64
-
65
- return fs.readdirSync(lanesDir)
66
- .filter(d => {
67
- const stat = fs.statSync(path.join(lanesDir, d));
68
- return stat.isDirectory();
69
- })
70
- .map(name => ({
71
- name,
72
- path: path.join(lanesDir, name),
73
- }));
74
- }
75
58
 
76
- /**
77
- * Get lane status
78
- */
79
- function getLaneStatus(lanePath: string): {
80
- status: string;
81
- currentTask: number | string;
82
- totalTasks: number | string;
83
- progress: string;
84
- pipelineBranch?: string;
85
- chatId?: string;
86
- } {
87
- const statePath = path.join(lanePath, 'state.json');
88
- const state = loadState<LaneState & { chatId?: string }>(statePath);
89
-
90
- if (!state) {
59
+ private setupTerminal() {
60
+ if (process.stdin.isTTY) {
61
+ process.stdin.setRawMode(true);
62
+ }
63
+ readline.emitKeypressEvents(process.stdin);
64
+ process.stdin.on('keypress', (str, key) => {
65
+ // Handle Ctrl+C
66
+ if (key && key.ctrl && key.name === 'c') {
67
+ this.stop();
68
+ return;
69
+ }
70
+
71
+ // Safeguard against missing key object
72
+ const keyName = key ? key.name : str;
73
+
74
+ if (this.view === View.LIST) {
75
+ this.handleListKey(keyName);
76
+ } else if (this.view === View.LANE_DETAIL) {
77
+ this.handleDetailKey(keyName);
78
+ } else if (this.view === View.FLOW) {
79
+ this.handleFlowKey(keyName);
80
+ } else if (this.view === View.TERMINAL) {
81
+ this.handleTerminalKey(keyName);
82
+ } else if (this.view === View.INTERVENE) {
83
+ this.handleInterveneKey(str, key);
84
+ } else {
85
+ this.handleMessageDetailKey(keyName);
86
+ }
87
+ });
88
+
89
+ // Hide cursor
90
+ process.stdout.write('\x1B[?25l');
91
+ }
92
+
93
+ private stop() {
94
+ if (this.timer) clearInterval(this.timer);
95
+ // Show cursor and clear screen
96
+ process.stdout.write('\x1B[?25h');
97
+ process.stdout.write('\x1Bc');
98
+ console.log('\nšŸ‘‹ Monitoring stopped\n');
99
+ process.exit(0);
100
+ }
101
+
102
+ private handleListKey(key: string) {
103
+ switch (key) {
104
+ case 'up':
105
+ this.selectedLaneIndex = Math.max(0, this.selectedLaneIndex - 1);
106
+ this.render();
107
+ break;
108
+ case 'down':
109
+ this.selectedLaneIndex = Math.min(this.lanes.length - 1, this.selectedLaneIndex + 1);
110
+ this.render();
111
+ break;
112
+ case 'right':
113
+ case 'return':
114
+ case 'enter':
115
+ if (this.lanes[this.selectedLaneIndex]) {
116
+ this.selectedLaneName = this.lanes[this.selectedLaneIndex]!.name;
117
+ this.view = View.LANE_DETAIL;
118
+ this.selectedMessageIndex = 0;
119
+ this.scrollOffset = 0;
120
+ this.refreshLogs();
121
+ this.render();
122
+ }
123
+ break;
124
+ case 'left':
125
+ case 'f':
126
+ this.view = View.FLOW;
127
+ this.render();
128
+ break;
129
+ case 'q':
130
+ this.stop();
131
+ break;
132
+ }
133
+ }
134
+
135
+ private handleDetailKey(key: string) {
136
+ switch (key) {
137
+ case 'up':
138
+ this.selectedMessageIndex = Math.max(0, this.selectedMessageIndex - 1);
139
+ this.render();
140
+ break;
141
+ case 'down':
142
+ this.selectedMessageIndex = Math.min(this.currentLogs.length - 1, this.selectedMessageIndex + 1);
143
+ this.render();
144
+ break;
145
+ case 'right':
146
+ case 'return':
147
+ case 'enter':
148
+ if (this.currentLogs[this.selectedMessageIndex]) {
149
+ this.view = View.MESSAGE_DETAIL;
150
+ this.render();
151
+ }
152
+ break;
153
+ case 't':
154
+ this.view = View.TERMINAL;
155
+ this.terminalScrollOffset = 0;
156
+ this.render();
157
+ break;
158
+ case 'i':
159
+ const lane = this.lanes.find(l => l.name === this.selectedLaneName);
160
+ if (lane) {
161
+ const status = this.getLaneStatus(lane.path, lane.name);
162
+ if (status.status === 'running') {
163
+ this.view = View.INTERVENE;
164
+ this.interventionInput = '';
165
+ this.render();
166
+ } else {
167
+ // Show a temporary message that it's only for running lanes
168
+ console.log(`\n\x1b[31m Intervention is only available for RUNNING lanes.\x1b[0m`);
169
+ setTimeout(() => this.render(), 1500);
170
+ }
171
+ }
172
+ break;
173
+ case 'escape':
174
+ case 'backspace':
175
+ case 'left':
176
+ this.view = View.LIST;
177
+ this.selectedLaneName = null;
178
+ this.render();
179
+ break;
180
+ case 'q':
181
+ this.stop();
182
+ break;
183
+ }
184
+ }
185
+
186
+ private handleMessageDetailKey(key: string) {
187
+ switch (key) {
188
+ case 'escape':
189
+ case 'backspace':
190
+ case 'left':
191
+ this.view = View.LANE_DETAIL;
192
+ this.render();
193
+ break;
194
+ case 'q':
195
+ this.stop();
196
+ break;
197
+ }
198
+ }
199
+
200
+ private handleTerminalKey(key: string) {
201
+ switch (key) {
202
+ case 'up':
203
+ this.terminalScrollOffset++;
204
+ this.render();
205
+ break;
206
+ case 'down':
207
+ this.terminalScrollOffset = Math.max(0, this.terminalScrollOffset - 1);
208
+ this.render();
209
+ break;
210
+ case 't':
211
+ case 'escape':
212
+ case 'backspace':
213
+ case 'left':
214
+ this.view = View.LANE_DETAIL;
215
+ this.render();
216
+ break;
217
+ case 'i':
218
+ this.view = View.INTERVENE;
219
+ this.interventionInput = '';
220
+ this.render();
221
+ break;
222
+ case 'q':
223
+ this.stop();
224
+ break;
225
+ }
226
+ }
227
+
228
+ private handleInterveneKey(str: string, key: any) {
229
+ if (key.name === 'return' || key.name === 'enter') {
230
+ if (this.interventionInput.trim()) {
231
+ this.sendIntervention(this.interventionInput.trim());
232
+ }
233
+ this.view = View.LANE_DETAIL;
234
+ this.render();
235
+ } else if (key.name === 'escape') {
236
+ this.view = View.LANE_DETAIL;
237
+ this.render();
238
+ } else if (key.name === 'backspace') {
239
+ this.interventionInput = this.interventionInput.slice(0, -1);
240
+ this.render();
241
+ } else if (str && str.length === 1) {
242
+ this.interventionInput += str;
243
+ this.render();
244
+ }
245
+ }
246
+
247
+ private sendIntervention(message: string) {
248
+ if (!this.selectedLaneName) return;
249
+ const lane = this.lanes.find(l => l.name === this.selectedLaneName);
250
+ if (!lane) return;
251
+
252
+ // Write to intervention.txt which runner.ts is watching
253
+ const interventionPath = path.join(path.dirname(lane.path), 'intervention.txt');
254
+ fs.writeFileSync(interventionPath, message, 'utf8');
255
+
256
+ // Also log it to the conversation
257
+ const convoPath = path.join(lane.path, 'conversation.jsonl');
258
+ const entry = {
259
+ timestamp: new Date().toISOString(),
260
+ role: 'user',
261
+ task: 'INTERVENTION',
262
+ fullText: `[HUMAN INTERVENTION]: ${message}`,
263
+ textLength: message.length + 20,
264
+ model: 'manual'
265
+ };
266
+ fs.appendFileSync(convoPath, JSON.stringify(entry) + '\n', 'utf8');
267
+ }
268
+
269
+ private handleFlowKey(key: string) {
270
+ switch (key) {
271
+ case 'f':
272
+ case 'escape':
273
+ case 'backspace':
274
+ case 'right':
275
+ case 'return':
276
+ case 'enter':
277
+ case 'left':
278
+ this.view = View.LIST;
279
+ this.render();
280
+ break;
281
+ case 'q':
282
+ this.stop();
283
+ break;
284
+ }
285
+ }
286
+
287
+ private refreshLogs() {
288
+ if (!this.selectedLaneName) return;
289
+ const lane = this.lanes.find(l => l.name === this.selectedLaneName);
290
+ if (!lane) return;
291
+ const convoPath = path.join(lane.path, 'conversation.jsonl');
292
+ this.currentLogs = readLog<ConversationEntry>(convoPath);
293
+ // Keep selection in bounds after refresh
294
+ if (this.selectedMessageIndex >= this.currentLogs.length) {
295
+ this.selectedMessageIndex = Math.max(0, this.currentLogs.length - 1);
296
+ }
297
+ }
298
+
299
+ private refresh() {
300
+ this.lanes = this.listLanesWithDeps(this.runDir);
301
+ if (this.view !== View.LIST) {
302
+ this.refreshLogs();
303
+ }
304
+ this.render();
305
+ }
306
+
307
+ private render() {
308
+ // Clear screen
309
+ process.stdout.write('\x1Bc');
310
+
311
+ switch (this.view) {
312
+ case View.LIST:
313
+ this.renderList();
314
+ break;
315
+ case View.LANE_DETAIL:
316
+ this.renderLaneDetail();
317
+ break;
318
+ case View.MESSAGE_DETAIL:
319
+ this.renderMessageDetail();
320
+ break;
321
+ case View.FLOW:
322
+ this.renderFlow();
323
+ break;
324
+ case View.TERMINAL:
325
+ this.renderTerminal();
326
+ break;
327
+ case View.INTERVENE:
328
+ this.renderIntervene();
329
+ break;
330
+ }
331
+ }
332
+
333
+ private renderList() {
334
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
335
+ console.log(`šŸ“Š CursorFlow Monitor - Run: ${path.basename(this.runDir)}`);
336
+ console.log(`šŸ•’ Updated: ${new Date().toLocaleTimeString()} | [↑/↓/→] Nav [←] Flow [Q] Quit`);
337
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
338
+
339
+ if (this.lanes.length === 0) {
340
+ console.log(' No lanes found\n');
341
+ return;
342
+ }
343
+
344
+ const laneStatuses: Record<string, any> = {};
345
+ this.lanes.forEach(l => laneStatuses[l.name] = this.getLaneStatus(l.path, l.name));
346
+
347
+ const maxNameLen = Math.max(...this.lanes.map(l => l.name.length), 15);
348
+ console.log(` ${'Lane'.padEnd(maxNameLen)} Status Progress Time Tasks Next Action`);
349
+ console.log(` ${'─'.repeat(maxNameLen)} ${'─'.repeat(18)} ${'─'.repeat(8)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(20)}`);
350
+
351
+ this.lanes.forEach((lane, i) => {
352
+ const isSelected = i === this.selectedLaneIndex;
353
+ const status = laneStatuses[lane.name];
354
+ const statusIcon = this.getStatusIcon(status.status);
355
+ const statusText = `${statusIcon} ${status.status}`.padEnd(18);
356
+ const progressText = status.progress.padEnd(8);
357
+ const timeText = this.formatDuration(status.duration).padEnd(8);
358
+
359
+ let tasksDisplay = '-';
360
+ if (typeof status.totalTasks === 'number') {
361
+ tasksDisplay = `${status.currentTask}/${status.totalTasks}`;
362
+ }
363
+ const tasksText = tasksDisplay.padEnd(6);
364
+
365
+ // Determine "Next Action"
366
+ let nextAction = '-';
367
+ if (status.status === 'completed') {
368
+ const dependents = this.lanes.filter(l => laneStatuses[l.name].dependsOn.includes(lane.name));
369
+ if (dependents.length > 0) {
370
+ nextAction = `Unlock: ${dependents.map(d => d.name).join(', ')}`;
371
+ } else {
372
+ nextAction = 'šŸ Done';
373
+ }
374
+ } else if (status.status === 'waiting') {
375
+ const missingDeps = status.dependsOn.filter((d: string) => laneStatuses[d] && laneStatuses[d].status !== 'completed');
376
+ nextAction = `Wait for: ${missingDeps.join(', ')}`;
377
+ } else if (status.status === 'running') {
378
+ nextAction = 'šŸš€ Working...';
379
+ }
380
+
381
+ const prefix = isSelected ? ' ā–¶ ' : ' ';
382
+ const line = `${prefix}${lane.name.padEnd(maxNameLen)} ${statusText} ${progressText} ${timeText} ${tasksText} ${nextAction}`;
383
+
384
+ if (isSelected) {
385
+ process.stdout.write(`\x1b[36m${line}\x1b[0m\n`);
386
+ } else {
387
+ process.stdout.write(`${line}\n`);
388
+ }
389
+ });
390
+
391
+ console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
392
+ }
393
+
394
+ private renderLaneDetail() {
395
+ const lane = this.lanes.find(l => l.name === this.selectedLaneName);
396
+ if (!lane) {
397
+ this.view = View.LIST;
398
+ this.render();
399
+ return;
400
+ }
401
+
402
+ const status = this.getLaneStatus(lane.path, lane.name);
403
+ const logPath = path.join(lane.path, 'terminal.log');
404
+ let liveLog = '(No live terminal output)';
405
+ if (fs.existsSync(logPath)) {
406
+ const content = fs.readFileSync(logPath, 'utf8');
407
+ liveLog = content.split('\n').slice(-15).join('\n');
408
+ }
409
+
410
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
411
+ console.log(`šŸ” Lane: ${lane.name}`);
412
+ console.log(`šŸ•’ Updated: ${new Date().toLocaleTimeString()} | [↑/↓/→] Browse History [Esc/←] Back`);
413
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
414
+
415
+ process.stdout.write(` Status: ${this.getStatusIcon(status.status)} ${status.status}\n`);
416
+ process.stdout.write(` Progress: ${status.progress} (${status.currentTask}/${status.totalTasks} tasks)\n`);
417
+ process.stdout.write(` Time: ${this.formatDuration(status.duration)}\n`);
418
+ process.stdout.write(` Branch: ${status.pipelineBranch}\n`);
419
+ process.stdout.write(` Chat ID: ${status.chatId}\n`);
420
+ process.stdout.write(` Depends: ${status.dependsOn.join(', ') || 'None'}\n`);
421
+
422
+ if (status.error) {
423
+ process.stdout.write(`\x1b[31m Error: ${status.error}\x1b[0m\n`);
424
+ }
425
+
426
+ console.log('\nšŸ–„ļø Live Terminal Output (Last 15 lines):');
427
+ console.log('─'.repeat(80));
428
+ console.log(`\x1b[90m${liveLog}\x1b[0m`);
429
+
430
+ console.log('\nšŸ’¬ Conversation History (Select to see full details):');
431
+ console.log('─'.repeat(80));
432
+ process.stdout.write(' [↑/↓] Browse | [→/Enter] Full Msg | [I] Intervene | [T] Live Terminal | [Esc/←] Back\n\n');
433
+
434
+ if (this.currentLogs.length === 0) {
435
+ console.log(' (No messages yet)');
436
+ } else {
437
+ // Simple windowed view for long histories
438
+ const maxVisible = 15; // Number of messages to show
439
+ if (this.selectedMessageIndex < this.scrollOffset) {
440
+ this.scrollOffset = this.selectedMessageIndex;
441
+ } else if (this.selectedMessageIndex >= this.scrollOffset + maxVisible) {
442
+ this.scrollOffset = this.selectedMessageIndex - maxVisible + 1;
443
+ }
444
+
445
+ const visibleLogs = this.currentLogs.slice(this.scrollOffset, this.scrollOffset + maxVisible);
446
+
447
+ visibleLogs.forEach((log, i) => {
448
+ const actualIndex = i + this.scrollOffset;
449
+ const isSelected = actualIndex === this.selectedMessageIndex;
450
+ const roleColor = log.role === 'user' ? '\x1b[33m' : log.role === 'reviewer' ? '\x1b[35m' : '\x1b[32m';
451
+ const role = log.role.toUpperCase().padEnd(10);
452
+
453
+ const prefix = isSelected ? 'ā–¶ ' : ' ';
454
+ const header = `${prefix}${roleColor}${role}\x1b[0m [${new Date(log.timestamp).toLocaleTimeString()}]`;
455
+
456
+ if (isSelected) {
457
+ process.stdout.write(`\x1b[48;5;236m${header}\x1b[0m\n`);
458
+ } else {
459
+ process.stdout.write(`${header}\n`);
460
+ }
461
+
462
+ const lines = log.fullText.split('\n').filter(l => l.trim());
463
+ const preview = lines[0]?.substring(0, 70) || '...';
464
+ process.stdout.write(` ${preview}${log.fullText.length > 70 ? '...' : ''}\n\n`);
465
+ });
466
+
467
+ if (this.currentLogs.length > maxVisible) {
468
+ console.log(` -- (${this.currentLogs.length - maxVisible} more messages, use ↑/↓ to scroll) --`);
469
+ }
470
+ }
471
+
472
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
473
+ }
474
+
475
+ private renderMessageDetail() {
476
+ const log = this.currentLogs[this.selectedMessageIndex];
477
+ if (!log) {
478
+ this.view = View.LANE_DETAIL;
479
+ this.render();
480
+ return;
481
+ }
482
+
483
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
484
+ console.log(`šŸ“„ Full Message Detail - ${log.role.toUpperCase()}`);
485
+ console.log(`šŸ•’ ${new Date(log.timestamp).toLocaleString()} | [Esc/←] Back to History`);
486
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
487
+
488
+ const roleColor = log.role === 'user' ? '\x1b[33m' : log.role === 'reviewer' ? '\x1b[35m' : '\x1b[32m';
489
+ process.stdout.write(`${roleColor}ROLE: ${log.role.toUpperCase()}\x1b[0m\n`);
490
+ if (log.model) process.stdout.write(`MODEL: ${log.model}\n`);
491
+ if (log.task) process.stdout.write(`TASK: ${log.task}\n`);
492
+ console.log('─'.repeat(40));
493
+ console.log(log.fullText);
494
+ console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
495
+ }
496
+
497
+ private renderFlow() {
498
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
499
+ console.log(`ā›“ļø Task Dependency Flow`);
500
+ console.log(`šŸ•’ Updated: ${new Date().toLocaleTimeString()} | [→/Enter/Esc] Back to List`);
501
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
502
+
503
+ const laneMap = new Map<string, any>();
504
+ this.lanes.forEach(lane => {
505
+ laneMap.set(lane.name, this.getLaneStatus(lane.path, lane.name));
506
+ });
507
+
508
+ // Enhanced visualization with box-like structure and clear connections
509
+ this.lanes.forEach(lane => {
510
+ const status = laneMap.get(lane.name);
511
+ const statusIcon = this.getStatusIcon(status.status);
512
+
513
+ let statusColor = '\x1b[90m'; // Grey for pending/waiting
514
+ if (status.status === 'completed') statusColor = '\x1b[32m'; // Green
515
+ if (status.status === 'running') statusColor = '\x1b[36m'; // Cyan
516
+ if (status.status === 'failed') statusColor = '\x1b[31m'; // Red
517
+
518
+ // Render the node
519
+ const nodeText = `[ ${statusIcon} ${lane.name.padEnd(18)} ]`;
520
+ process.stdout.write(` ${statusColor}${nodeText}\x1b[0m`);
521
+
522
+ // Render dependencies
523
+ if (status.dependsOn && status.dependsOn.length > 0) {
524
+ process.stdout.write(` \x1b[90m◀───\x1b[0m \x1b[33m( ${status.dependsOn.join(', ')} )\x1b[0m`);
525
+ }
526
+ process.stdout.write('\n');
527
+ });
528
+
529
+ console.log('\n\x1b[90m (Lanes wait for their dependencies to complete before starting)\x1b[0m');
530
+ console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
531
+ }
532
+
533
+ private renderTerminal() {
534
+ const lane = this.lanes.find(l => l.name === this.selectedLaneName);
535
+ if (!lane) {
536
+ this.view = View.LIST;
537
+ this.render();
538
+ return;
539
+ }
540
+
541
+ const logPath = path.join(lane.path, 'terminal.log');
542
+ let logLines: string[] = [];
543
+ if (fs.existsSync(logPath)) {
544
+ const content = fs.readFileSync(logPath, 'utf8');
545
+ logLines = content.split('\n');
546
+ }
547
+
548
+ const maxVisible = 40;
549
+ const totalLines = logLines.length;
550
+
551
+ // Sticky scroll logic: if new lines arrived and we are already scrolled up,
552
+ // increase the offset to stay on the same content.
553
+ if (this.terminalScrollOffset > 0 && this.lastTerminalTotalLines > 0 && totalLines > this.lastTerminalTotalLines) {
554
+ this.terminalScrollOffset += (totalLines - this.lastTerminalTotalLines);
555
+ }
556
+ this.lastTerminalTotalLines = totalLines;
557
+
558
+ // Clamp scroll offset
559
+ const maxScroll = Math.max(0, totalLines - maxVisible);
560
+ if (this.terminalScrollOffset > maxScroll) {
561
+ this.terminalScrollOffset = maxScroll;
562
+ }
563
+
564
+ // Slice based on scroll (0 means bottom, >0 means scrolled up)
565
+ const end = totalLines - this.terminalScrollOffset;
566
+ const start = Math.max(0, end - maxVisible);
567
+ const visibleLines = logLines.slice(start, end);
568
+
569
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
570
+ console.log(`šŸ–„ļø Full Live Terminal: ${lane.name}`);
571
+ console.log(`šŸ•’ Streaming... | [↑/↓] Scroll (${this.terminalScrollOffset > 0 ? `Scrolled Up ${this.terminalScrollOffset}` : 'Bottom'}) | [I] Intervene | [T/Esc/←] Back`);
572
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
573
+
574
+ visibleLines.forEach(line => {
575
+ let formattedLine = line;
576
+ // Highlight human intervention
577
+ if (line.includes('[HUMAN INTERVENTION]') || line.includes('Injecting intervention:')) {
578
+ formattedLine = `\x1b[33m\x1b[1m${line}\x1b[0m`;
579
+ }
580
+ // Highlight agent execution starts
581
+ else if (line.includes('Executing cursor-agent')) {
582
+ formattedLine = `\x1b[36m\x1b[1m${line}\x1b[0m`;
583
+ }
584
+ // Highlight task headers
585
+ else if (line.includes('=== Task:')) {
586
+ formattedLine = `\x1b[32m\x1b[1m${line}\x1b[0m`;
587
+ }
588
+ // Highlight errors
589
+ else if (line.toLowerCase().includes('error') || line.toLowerCase().includes('failed')) {
590
+ formattedLine = `\x1b[31m${line}\x1b[0m`;
591
+ }
592
+
593
+ process.stdout.write(` ${formattedLine}\n`);
594
+ });
595
+
596
+ console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
597
+ }
598
+
599
+ private renderIntervene() {
600
+ console.clear();
601
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
602
+ console.log(`šŸ™‹ HUMAN INTERVENTION: ${this.selectedLaneName}`);
603
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
604
+ console.log('\n Type your message to the agent. This will be sent as a direct prompt.');
605
+ console.log(` Press \x1b[1mENTER\x1b[0m to send, \x1b[1mESC\x1b[0m to cancel.\n`);
606
+ console.log(`\x1b[33m > \x1b[0m${this.interventionInput}\x1b[37mā–ˆ\x1b[0m`);
607
+ console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
608
+ }
609
+
610
+ private listLanesWithDeps(runDir: string): LaneWithDeps[] {
611
+ const lanesDir = path.join(runDir, 'lanes');
612
+ if (!fs.existsSync(lanesDir)) return [];
613
+
614
+ const config = loadConfig();
615
+ const tasksDir = path.join(config.projectRoot, config.tasksDir);
616
+
617
+ const laneConfigs = this.listLaneFilesFromDir(tasksDir);
618
+
619
+ return fs.readdirSync(lanesDir)
620
+ .filter(d => fs.statSync(path.join(lanesDir, d)).isDirectory())
621
+ .map(name => {
622
+ const config = laneConfigs.find(c => c.name === name);
623
+ return {
624
+ name,
625
+ path: path.join(lanesDir, name),
626
+ dependsOn: config?.dependsOn || [],
627
+ };
628
+ });
629
+ }
630
+
631
+ private listLaneFilesFromDir(tasksDir: string): { name: string; dependsOn: string[] }[] {
632
+ if (!fs.existsSync(tasksDir)) return [];
633
+ return fs.readdirSync(tasksDir)
634
+ .filter(f => f.endsWith('.json'))
635
+ .map(f => {
636
+ const filePath = path.join(tasksDir, f);
637
+ try {
638
+ const config = JSON.parse(fs.readFileSync(filePath, 'utf8'));
639
+ return { name: path.basename(f, '.json'), dependsOn: config.dependsOn || [] };
640
+ } catch {
641
+ return { name: path.basename(f, '.json'), dependsOn: [] };
642
+ }
643
+ });
644
+ }
645
+
646
+ private getLaneStatus(lanePath: string, laneName: string) {
647
+ const statePath = path.join(lanePath, 'state.json');
648
+ const state = loadState<LaneState & { chatId?: string }>(statePath);
649
+
650
+ const laneInfo = this.lanes.find(l => l.name === laneName);
651
+ const dependsOn = state?.dependsOn || laneInfo?.dependsOn || [];
652
+
653
+ if (!state) {
654
+ return { status: 'pending', currentTask: 0, totalTasks: '?', progress: '0%', dependsOn, duration: 0, pipelineBranch: '-', chatId: '-' };
655
+ }
656
+
657
+ const progress = state.totalTasks > 0 ? Math.round((state.currentTaskIndex / state.totalTasks) * 100) : 0;
658
+
659
+ const duration = state.startTime ? (state.endTime
660
+ ? state.endTime - state.startTime
661
+ : (state.status === 'running' || state.status === 'reviewing' ? Date.now() - state.startTime : 0)) : 0;
662
+
91
663
  return {
92
- status: 'no state',
93
- currentTask: '-',
94
- totalTasks: '?',
95
- progress: '0%',
664
+ status: state.status || 'unknown',
665
+ currentTask: state.currentTaskIndex || 0,
666
+ totalTasks: state.totalTasks || '?',
667
+ progress: `${progress}%`,
668
+ pipelineBranch: state.pipelineBranch || '-',
669
+ chatId: state.chatId || '-',
670
+ dependsOn,
671
+ duration,
672
+ error: state.error,
96
673
  };
97
674
  }
98
-
99
- const progress = state.totalTasks > 0
100
- ? Math.round((state.currentTaskIndex / state.totalTasks) * 100)
101
- : 0;
102
-
103
- return {
104
- status: state.status || 'unknown',
105
- currentTask: (state.currentTaskIndex || 0) + 1,
106
- totalTasks: state.totalTasks || '?',
107
- progress: `${progress}%`,
108
- pipelineBranch: state.pipelineBranch || '-',
109
- chatId: state.chatId || '-',
110
- };
111
- }
112
675
 
113
- /**
114
- * Get status icon
115
- */
116
- function getStatusIcon(status: string): string {
117
- const icons: Record<string, string> = {
118
- 'running': 'šŸ”„',
119
- 'completed': 'āœ…',
120
- 'failed': 'āŒ',
121
- 'blocked_dependency': '🚫',
122
- 'no state': '⚪',
123
- };
124
-
125
- return icons[status] || 'ā“';
676
+ private formatDuration(ms: number): string {
677
+ if (ms <= 0) return '-';
678
+ const seconds = Math.floor((ms / 1000) % 60);
679
+ const minutes = Math.floor((ms / (1000 * 60)) % 60);
680
+ const hours = Math.floor(ms / (1000 * 60 * 60));
681
+
682
+ if (hours > 0) return `${hours}h ${minutes}m`;
683
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
684
+ return `${seconds}s`;
685
+ }
686
+
687
+ private getStatusIcon(status: string): string {
688
+ const icons: Record<string, string> = {
689
+ 'running': 'šŸ”„',
690
+ 'waiting': 'ā³',
691
+ 'completed': 'āœ…',
692
+ 'failed': 'āŒ',
693
+ 'blocked_dependency': '🚫',
694
+ 'pending': '⚪',
695
+ 'reviewing': 'šŸ‘€',
696
+ };
697
+ return icons[status] || 'ā“';
698
+ }
126
699
  }
127
700
 
128
701
  /**
129
- * Display lane status table
702
+ * Find the latest run directory
130
703
  */
131
- function displayStatus(runDir: string, lanes: { name: string; path: string }[]): void {
132
- console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
133
- console.log(`šŸ“Š Run: ${path.basename(runDir)}`);
134
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
135
-
136
- if (lanes.length === 0) {
137
- console.log(' No lanes found\n');
138
- return;
139
- }
140
-
141
- // Calculate column widths
142
- const maxNameLen = Math.max(...lanes.map(l => l.name.length), 10);
143
-
144
- // Header
145
- console.log(` ${'Lane'.padEnd(maxNameLen)} Status Progress Tasks`);
146
- console.log(` ${'─'.repeat(maxNameLen)} ${'─'.repeat(18)} ${'─'.repeat(8)} ${'─'.repeat(10)}`);
147
-
148
- // Lanes
149
- for (const lane of lanes) {
150
- const status = getLaneStatus(lane.path);
151
- const statusIcon = getStatusIcon(status.status);
152
- const statusText = `${statusIcon} ${status.status}`.padEnd(18);
153
- const progressText = status.progress.padEnd(8);
154
- const tasksText = `${status.currentTask}/${status.totalTasks}`;
155
-
156
- console.log(` ${lane.name.padEnd(maxNameLen)} ${statusText} ${progressText} ${tasksText}`);
157
- }
158
-
159
- console.log();
704
+ function findLatestRunDir(logsDir: string): string | null {
705
+ const runsDir = path.join(logsDir, 'runs');
706
+ if (!fs.existsSync(runsDir)) return null;
707
+ const runs = fs.readdirSync(runsDir)
708
+ .filter(d => d.startsWith('run-'))
709
+ .map(d => ({ name: d, path: path.join(runsDir, d), mtime: fs.statSync(path.join(runsDir, d)).mtime.getTime() }))
710
+ .sort((a, b) => b.mtime - a.mtime);
711
+ return runs.length > 0 ? runs[0]!.path : null;
160
712
  }
161
713
 
162
714
  /**
163
715
  * Monitor lanes
164
716
  */
165
717
  async function monitor(args: string[]): Promise<void> {
166
- logger.section('šŸ“” Monitoring Lane Execution');
718
+ const watchIdx = args.indexOf('--watch');
719
+ const intervalIdx = args.indexOf('--interval');
720
+ const interval = intervalIdx >= 0 ? parseInt(args[intervalIdx + 1] || '2') || 2 : 2;
167
721
 
168
- const options = parseArgs(args);
722
+ const runDirArg = args.find(arg => !arg.startsWith('--') && args.indexOf(arg) !== intervalIdx + 1);
169
723
  const config = loadConfig();
170
724
 
171
- // Determine run directory
172
- let runDir = options.runDir;
173
-
725
+ let runDir = runDirArg;
174
726
  if (!runDir || runDir === 'latest') {
175
727
  runDir = findLatestRunDir(config.logsDir) || undefined;
176
-
177
- if (!runDir) {
178
- logger.error(`Runs directory: ${path.join(config.logsDir, 'runs')}`);
179
- throw new Error('No run directories found');
180
- }
181
-
182
- logger.info(`Using latest run: ${path.basename(runDir)}`);
728
+ if (!runDir) throw new Error('No run directories found');
183
729
  }
184
730
 
185
- if (!fs.existsSync(runDir)) {
186
- throw new Error(`Run directory not found: ${runDir}`);
187
- }
731
+ if (!fs.existsSync(runDir)) throw new Error(`Run directory not found: ${runDir}`);
188
732
 
189
- // Watch mode
190
- if (options.watch) {
191
- logger.info(`Watch mode: every ${options.interval}s (Ctrl+C to stop)\n`);
192
-
193
- let iteration = 0;
194
-
195
- const refresh = () => {
196
- if (iteration > 0) {
197
- // Clear screen
198
- process.stdout.write('\x1Bc');
199
- }
200
-
201
- const lanes = listLanes(runDir!);
202
- displayStatus(runDir!, lanes);
203
-
204
- iteration++;
205
- };
206
-
207
- // Initial display
208
- refresh();
209
-
210
- // Set up interval
211
- const intervalId = setInterval(refresh, options.interval * 1000);
212
-
213
- // Handle Ctrl+C
214
- return new Promise((_, reject) => {
215
- process.on('SIGINT', () => {
216
- clearInterval(intervalId);
217
- console.log('\nšŸ‘‹ Monitoring stopped\n');
218
- process.exit(0);
219
- });
220
- });
221
-
222
- } else {
223
- // Single shot
224
- const lanes = listLanes(runDir);
225
- displayStatus(runDir, lanes);
226
- }
733
+ const monitor = new InteractiveMonitor(runDir, interval);
734
+ await monitor.start();
227
735
  }
228
736
 
229
737
  export = monitor;