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