@litmers/cursorflow-orchestrator 0.1.20 → 0.1.28

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 (224) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/commands/cursorflow-clean.md +19 -0
  3. package/commands/cursorflow-runs.md +59 -0
  4. package/commands/cursorflow-stop.md +55 -0
  5. package/dist/cli/clean.js +171 -0
  6. package/dist/cli/clean.js.map +1 -1
  7. package/dist/cli/index.js +7 -0
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/cli/init.js +1 -1
  10. package/dist/cli/init.js.map +1 -1
  11. package/dist/cli/logs.js +83 -42
  12. package/dist/cli/logs.js.map +1 -1
  13. package/dist/cli/monitor.d.ts +7 -0
  14. package/dist/cli/monitor.js +1007 -189
  15. package/dist/cli/monitor.js.map +1 -1
  16. package/dist/cli/prepare.js +87 -3
  17. package/dist/cli/prepare.js.map +1 -1
  18. package/dist/cli/resume.js +188 -236
  19. package/dist/cli/resume.js.map +1 -1
  20. package/dist/cli/run.js +125 -3
  21. package/dist/cli/run.js.map +1 -1
  22. package/dist/cli/runs.d.ts +5 -0
  23. package/dist/cli/runs.js +214 -0
  24. package/dist/cli/runs.js.map +1 -0
  25. package/dist/cli/setup-commands.js +0 -0
  26. package/dist/cli/signal.js +1 -1
  27. package/dist/cli/signal.js.map +1 -1
  28. package/dist/cli/stop.d.ts +5 -0
  29. package/dist/cli/stop.js +215 -0
  30. package/dist/cli/stop.js.map +1 -0
  31. package/dist/cli/tasks.d.ts +10 -0
  32. package/dist/cli/tasks.js +165 -0
  33. package/dist/cli/tasks.js.map +1 -0
  34. package/dist/core/auto-recovery.d.ts +212 -0
  35. package/dist/core/auto-recovery.js +737 -0
  36. package/dist/core/auto-recovery.js.map +1 -0
  37. package/dist/core/failure-policy.d.ts +156 -0
  38. package/dist/core/failure-policy.js +488 -0
  39. package/dist/core/failure-policy.js.map +1 -0
  40. package/dist/core/orchestrator.d.ts +15 -2
  41. package/dist/core/orchestrator.js +397 -15
  42. package/dist/core/orchestrator.js.map +1 -1
  43. package/dist/core/reviewer.d.ts +2 -0
  44. package/dist/core/reviewer.js +2 -0
  45. package/dist/core/reviewer.js.map +1 -1
  46. package/dist/core/runner.d.ts +33 -10
  47. package/dist/core/runner.js +321 -146
  48. package/dist/core/runner.js.map +1 -1
  49. package/dist/services/logging/buffer.d.ts +67 -0
  50. package/dist/services/logging/buffer.js +309 -0
  51. package/dist/services/logging/buffer.js.map +1 -0
  52. package/dist/services/logging/console.d.ts +89 -0
  53. package/dist/services/logging/console.js +169 -0
  54. package/dist/services/logging/console.js.map +1 -0
  55. package/dist/services/logging/file-writer.d.ts +71 -0
  56. package/dist/services/logging/file-writer.js +516 -0
  57. package/dist/services/logging/file-writer.js.map +1 -0
  58. package/dist/services/logging/formatter.d.ts +39 -0
  59. package/dist/services/logging/formatter.js +227 -0
  60. package/dist/services/logging/formatter.js.map +1 -0
  61. package/dist/services/logging/index.d.ts +11 -0
  62. package/dist/services/logging/index.js +30 -0
  63. package/dist/services/logging/index.js.map +1 -0
  64. package/dist/services/logging/parser.d.ts +31 -0
  65. package/dist/services/logging/parser.js +222 -0
  66. package/dist/services/logging/parser.js.map +1 -0
  67. package/dist/services/process/index.d.ts +59 -0
  68. package/dist/services/process/index.js +257 -0
  69. package/dist/services/process/index.js.map +1 -0
  70. package/dist/types/agent.d.ts +20 -0
  71. package/dist/types/agent.js +6 -0
  72. package/dist/types/agent.js.map +1 -0
  73. package/dist/types/config.d.ts +65 -0
  74. package/dist/types/config.js +6 -0
  75. package/dist/types/config.js.map +1 -0
  76. package/dist/types/events.d.ts +125 -0
  77. package/dist/types/events.js +6 -0
  78. package/dist/types/events.js.map +1 -0
  79. package/dist/types/index.d.ts +12 -0
  80. package/dist/types/index.js +37 -0
  81. package/dist/types/index.js.map +1 -0
  82. package/dist/types/lane.d.ts +43 -0
  83. package/dist/types/lane.js +6 -0
  84. package/dist/types/lane.js.map +1 -0
  85. package/dist/types/logging.d.ts +71 -0
  86. package/dist/types/logging.js +16 -0
  87. package/dist/types/logging.js.map +1 -0
  88. package/dist/types/review.d.ts +17 -0
  89. package/dist/types/review.js +6 -0
  90. package/dist/types/review.js.map +1 -0
  91. package/dist/types/run.d.ts +32 -0
  92. package/dist/types/run.js +6 -0
  93. package/dist/types/run.js.map +1 -0
  94. package/dist/types/task.d.ts +71 -0
  95. package/dist/types/task.js +6 -0
  96. package/dist/types/task.js.map +1 -0
  97. package/dist/ui/components.d.ts +134 -0
  98. package/dist/ui/components.js +389 -0
  99. package/dist/ui/components.js.map +1 -0
  100. package/dist/ui/log-viewer.d.ts +49 -0
  101. package/dist/ui/log-viewer.js +449 -0
  102. package/dist/ui/log-viewer.js.map +1 -0
  103. package/dist/utils/checkpoint.d.ts +87 -0
  104. package/dist/utils/checkpoint.js +317 -0
  105. package/dist/utils/checkpoint.js.map +1 -0
  106. package/dist/utils/config.d.ts +4 -0
  107. package/dist/utils/config.js +11 -2
  108. package/dist/utils/config.js.map +1 -1
  109. package/dist/utils/cursor-agent.js.map +1 -1
  110. package/dist/utils/dependency.d.ts +74 -0
  111. package/dist/utils/dependency.js +420 -0
  112. package/dist/utils/dependency.js.map +1 -0
  113. package/dist/utils/doctor.js +10 -5
  114. package/dist/utils/doctor.js.map +1 -1
  115. package/dist/utils/enhanced-logger.d.ts +10 -33
  116. package/dist/utils/enhanced-logger.js +94 -9
  117. package/dist/utils/enhanced-logger.js.map +1 -1
  118. package/dist/utils/git.d.ts +121 -0
  119. package/dist/utils/git.js +322 -2
  120. package/dist/utils/git.js.map +1 -1
  121. package/dist/utils/health.d.ts +91 -0
  122. package/dist/utils/health.js +556 -0
  123. package/dist/utils/health.js.map +1 -0
  124. package/dist/utils/lock.d.ts +95 -0
  125. package/dist/utils/lock.js +332 -0
  126. package/dist/utils/lock.js.map +1 -0
  127. package/dist/utils/log-buffer.d.ts +17 -0
  128. package/dist/utils/log-buffer.js +14 -0
  129. package/dist/utils/log-buffer.js.map +1 -0
  130. package/dist/utils/log-constants.d.ts +23 -0
  131. package/dist/utils/log-constants.js +28 -0
  132. package/dist/utils/log-constants.js.map +1 -0
  133. package/dist/utils/log-formatter.d.ts +9 -0
  134. package/dist/utils/log-formatter.js +113 -70
  135. package/dist/utils/log-formatter.js.map +1 -1
  136. package/dist/utils/log-service.d.ts +19 -0
  137. package/dist/utils/log-service.js +47 -0
  138. package/dist/utils/log-service.js.map +1 -0
  139. package/dist/utils/logger.d.ts +46 -27
  140. package/dist/utils/logger.js +82 -60
  141. package/dist/utils/logger.js.map +1 -1
  142. package/dist/utils/process-manager.d.ts +21 -0
  143. package/dist/utils/process-manager.js +138 -0
  144. package/dist/utils/process-manager.js.map +1 -0
  145. package/dist/utils/retry.d.ts +121 -0
  146. package/dist/utils/retry.js +374 -0
  147. package/dist/utils/retry.js.map +1 -0
  148. package/dist/utils/run-service.d.ts +88 -0
  149. package/dist/utils/run-service.js +412 -0
  150. package/dist/utils/run-service.js.map +1 -0
  151. package/dist/utils/state.d.ts +58 -2
  152. package/dist/utils/state.js +306 -3
  153. package/dist/utils/state.js.map +1 -1
  154. package/dist/utils/task-service.d.ts +82 -0
  155. package/dist/utils/task-service.js +348 -0
  156. package/dist/utils/task-service.js.map +1 -0
  157. package/dist/utils/types.d.ts +2 -272
  158. package/dist/utils/types.js +16 -0
  159. package/dist/utils/types.js.map +1 -1
  160. package/package.json +38 -23
  161. package/scripts/ai-security-check.js +0 -1
  162. package/scripts/local-security-gate.sh +0 -0
  163. package/scripts/monitor-lanes.sh +94 -0
  164. package/scripts/patches/test-cursor-agent.js +0 -1
  165. package/scripts/release.sh +0 -0
  166. package/scripts/setup-security.sh +0 -0
  167. package/scripts/stream-logs.sh +72 -0
  168. package/scripts/verify-and-fix.sh +0 -0
  169. package/src/cli/clean.ts +180 -0
  170. package/src/cli/index.ts +7 -0
  171. package/src/cli/init.ts +1 -1
  172. package/src/cli/logs.ts +79 -42
  173. package/src/cli/monitor.ts +1815 -899
  174. package/src/cli/prepare.ts +97 -3
  175. package/src/cli/resume.ts +220 -277
  176. package/src/cli/run.ts +154 -3
  177. package/src/cli/runs.ts +212 -0
  178. package/src/cli/setup-commands.ts +0 -0
  179. package/src/cli/signal.ts +1 -1
  180. package/src/cli/stop.ts +209 -0
  181. package/src/cli/tasks.ts +154 -0
  182. package/src/core/auto-recovery.ts +909 -0
  183. package/src/core/failure-policy.ts +592 -0
  184. package/src/core/orchestrator.ts +1136 -675
  185. package/src/core/reviewer.ts +4 -0
  186. package/src/core/runner.ts +1443 -1217
  187. package/src/services/logging/buffer.ts +326 -0
  188. package/src/services/logging/console.ts +193 -0
  189. package/src/services/logging/file-writer.ts +526 -0
  190. package/src/services/logging/formatter.ts +268 -0
  191. package/src/services/logging/index.ts +16 -0
  192. package/src/services/logging/parser.ts +232 -0
  193. package/src/services/process/index.ts +261 -0
  194. package/src/types/agent.ts +24 -0
  195. package/src/types/config.ts +79 -0
  196. package/src/types/events.ts +156 -0
  197. package/src/types/index.ts +29 -0
  198. package/src/types/lane.ts +56 -0
  199. package/src/types/logging.ts +96 -0
  200. package/src/types/review.ts +20 -0
  201. package/src/types/run.ts +37 -0
  202. package/src/types/task.ts +79 -0
  203. package/src/ui/components.ts +430 -0
  204. package/src/ui/log-viewer.ts +485 -0
  205. package/src/utils/checkpoint.ts +374 -0
  206. package/src/utils/config.ts +11 -2
  207. package/src/utils/cursor-agent.ts +1 -1
  208. package/src/utils/dependency.ts +482 -0
  209. package/src/utils/doctor.ts +11 -5
  210. package/src/utils/enhanced-logger.ts +108 -49
  211. package/src/utils/git.ts +871 -499
  212. package/src/utils/health.ts +596 -0
  213. package/src/utils/lock.ts +346 -0
  214. package/src/utils/log-buffer.ts +28 -0
  215. package/src/utils/log-constants.ts +26 -0
  216. package/src/utils/log-formatter.ts +120 -37
  217. package/src/utils/log-service.ts +49 -0
  218. package/src/utils/logger.ts +100 -51
  219. package/src/utils/process-manager.ts +100 -0
  220. package/src/utils/retry.ts +413 -0
  221. package/src/utils/run-service.ts +433 -0
  222. package/src/utils/state.ts +369 -3
  223. package/src/utils/task-service.ts +370 -0
  224. package/src/utils/types.ts +2 -315
@@ -1,899 +1,1815 @@
1
- /**
2
- * CursorFlow interactive monitor command
3
- */
4
-
5
- import * as fs from 'fs';
6
- import * as path from 'path';
7
- import * as readline from 'readline';
8
- import * as logger from '../utils/logger';
9
- import { loadState, readLog } from '../utils/state';
10
- import { LaneState, ConversationEntry } from '../utils/types';
11
- import { loadConfig } from '../utils/config';
12
- import { safeJoin } from '../utils/path';
13
-
14
- interface LaneWithDeps {
15
- name: string;
16
- path: string;
17
- dependsOn: string[];
18
- }
19
-
20
- interface MonitorOptions {
21
- runDir?: string;
22
- interval: number;
23
- help: boolean;
24
- }
25
-
26
- function printHelp(): void {
27
- console.log(`
28
- Usage: cursorflow monitor [run-dir] [options]
29
-
30
- Interactive lane dashboard to track progress and dependencies.
31
-
32
- Options:
33
- [run-dir] Run directory to monitor (default: latest)
34
- --interval <seconds> Refresh interval (default: 2)
35
- --help, -h Show help
36
- `);
37
- }
38
-
39
- enum View {
40
- LIST,
41
- LANE_DETAIL,
42
- MESSAGE_DETAIL,
43
- FLOW,
44
- TERMINAL,
45
- INTERVENE,
46
- TIMEOUT
47
- }
48
-
49
- class InteractiveMonitor {
50
- private runDir: string;
51
- private interval: number;
52
- private view: View = View.LIST;
53
- private selectedLaneIndex: number = 0;
54
- private selectedMessageIndex: number = 0;
55
- private selectedLaneName: string | null = null;
56
- private lanes: LaneWithDeps[] = [];
57
- private currentLogs: ConversationEntry[] = [];
58
- private timer: NodeJS.Timeout | null = null;
59
- private scrollOffset: number = 0;
60
- private terminalScrollOffset: number = 0;
61
- private lastTerminalTotalLines: number = 0;
62
- private interventionInput: string = '';
63
- private timeoutInput: string = '';
64
- private notification: { message: string; type: 'info' | 'error' | 'success'; time: number } | null = null;
65
-
66
- constructor(runDir: string, interval: number) {
67
- this.runDir = runDir;
68
- this.interval = interval;
69
- }
70
-
71
- public async start() {
72
- this.setupTerminal();
73
- this.refresh();
74
- this.timer = setInterval(() => this.refresh(), this.interval * 1000);
75
- }
76
-
77
- private setupTerminal() {
78
- if (process.stdin.isTTY) {
79
- process.stdin.setRawMode(true);
80
- }
81
- readline.emitKeypressEvents(process.stdin);
82
- process.stdin.on('keypress', (str, key) => {
83
- // Handle Ctrl+C
84
- if (key && key.ctrl && key.name === 'c') {
85
- this.stop();
86
- return;
87
- }
88
-
89
- // Safeguard against missing key object
90
- const keyName = key ? key.name : str;
91
-
92
- if (this.view === View.LIST) {
93
- this.handleListKey(keyName);
94
- } else if (this.view === View.LANE_DETAIL) {
95
- this.handleDetailKey(keyName);
96
- } else if (this.view === View.FLOW) {
97
- this.handleFlowKey(keyName);
98
- } else if (this.view === View.TERMINAL) {
99
- this.handleTerminalKey(keyName);
100
- } else if (this.view === View.INTERVENE) {
101
- this.handleInterveneKey(str, key);
102
- } else if (this.view === View.TIMEOUT) {
103
- this.handleTimeoutKey(str, key);
104
- } else if (this.view === View.MESSAGE_DETAIL) {
105
- this.handleMessageDetailKey(keyName);
106
- }
107
- });
108
-
109
- // Hide cursor
110
- process.stdout.write('\x1B[?25l');
111
- }
112
-
113
- private stop() {
114
- if (this.timer) clearInterval(this.timer);
115
- // Show cursor and clear screen
116
- process.stdout.write('\x1B[?25h');
117
- process.stdout.write('\x1Bc');
118
- console.log('\n👋 Monitoring stopped\n');
119
- process.exit(0);
120
- }
121
-
122
- private handleListKey(key: string) {
123
- switch (key) {
124
- case 'up':
125
- this.selectedLaneIndex = Math.max(0, this.selectedLaneIndex - 1);
126
- this.render();
127
- break;
128
- case 'down':
129
- this.selectedLaneIndex = Math.min(this.lanes.length - 1, this.selectedLaneIndex + 1);
130
- this.render();
131
- break;
132
- case 'right':
133
- case 'return':
134
- case 'enter':
135
- if (this.lanes[this.selectedLaneIndex]) {
136
- this.selectedLaneName = this.lanes[this.selectedLaneIndex]!.name;
137
- this.view = View.LANE_DETAIL;
138
- this.selectedMessageIndex = 0;
139
- this.scrollOffset = 0;
140
- this.refreshLogs();
141
- this.render();
142
- }
143
- break;
144
- case 'left':
145
- case 'f':
146
- this.view = View.FLOW;
147
- this.render();
148
- break;
149
- case 'q':
150
- this.stop();
151
- break;
152
- }
153
- }
154
-
155
- private handleDetailKey(key: string) {
156
- switch (key) {
157
- case 'up':
158
- this.selectedMessageIndex = Math.max(0, this.selectedMessageIndex - 1);
159
- this.render();
160
- break;
161
- case 'down':
162
- this.selectedMessageIndex = Math.min(this.currentLogs.length - 1, this.selectedMessageIndex + 1);
163
- this.render();
164
- break;
165
- case 'right':
166
- case 'return':
167
- case 'enter':
168
- if (this.currentLogs[this.selectedMessageIndex]) {
169
- this.view = View.MESSAGE_DETAIL;
170
- this.render();
171
- }
172
- break;
173
- case 't':
174
- this.view = View.TERMINAL;
175
- this.terminalScrollOffset = 0;
176
- this.render();
177
- break;
178
- case 'k':
179
- this.killLane();
180
- break;
181
- case 'i':
182
- const lane = this.lanes.find(l => l.name === this.selectedLaneName);
183
- if (lane) {
184
- const status = this.getLaneStatus(lane.path, lane.name);
185
- if (status.status === 'running') {
186
- this.view = View.INTERVENE;
187
- this.interventionInput = '';
188
- this.render();
189
- } else {
190
- this.showNotification('Intervention only available for RUNNING lanes', 'error');
191
- }
192
- }
193
- break;
194
- case 'o':
195
- const timeoutLane = this.lanes.find(l => l.name === this.selectedLaneName);
196
- if (timeoutLane) {
197
- const status = this.getLaneStatus(timeoutLane.path, timeoutLane.name);
198
- if (status.status === 'running') {
199
- this.view = View.TIMEOUT;
200
- this.timeoutInput = '';
201
- this.render();
202
- } else {
203
- this.showNotification('Timeout update only available for RUNNING lanes', 'error');
204
- }
205
- }
206
- break;
207
- case 'escape':
208
- case 'backspace':
209
- case 'left':
210
- this.view = View.LIST;
211
- this.selectedLaneName = null;
212
- this.render();
213
- break;
214
- case 'q':
215
- this.stop();
216
- break;
217
- }
218
- }
219
-
220
- private handleMessageDetailKey(key: string) {
221
- switch (key) {
222
- case 'escape':
223
- case 'backspace':
224
- case 'left':
225
- this.view = View.LANE_DETAIL;
226
- this.render();
227
- break;
228
- case 'q':
229
- this.stop();
230
- break;
231
- }
232
- }
233
-
234
- private handleTerminalKey(key: string) {
235
- switch (key) {
236
- case 'up':
237
- this.terminalScrollOffset++;
238
- this.render();
239
- break;
240
- case 'down':
241
- this.terminalScrollOffset = Math.max(0, this.terminalScrollOffset - 1);
242
- this.render();
243
- break;
244
- case 't':
245
- case 'escape':
246
- case 'backspace':
247
- case 'left':
248
- this.view = View.LANE_DETAIL;
249
- this.render();
250
- break;
251
- case 'i':
252
- this.view = View.INTERVENE;
253
- this.interventionInput = '';
254
- this.render();
255
- break;
256
- case 'q':
257
- this.stop();
258
- break;
259
- }
260
- }
261
-
262
- private handleInterveneKey(str: string, key: any) {
263
- if (key && key.name === 'escape') {
264
- this.view = View.LANE_DETAIL;
265
- this.render();
266
- return;
267
- }
268
-
269
- if (key && (key.name === 'return' || key.name === 'enter')) {
270
- if (this.interventionInput.trim()) {
271
- this.sendIntervention(this.interventionInput.trim());
272
- }
273
- this.view = View.LANE_DETAIL;
274
- this.render();
275
- return;
276
- }
277
-
278
- if (key && key.name === 'backspace') {
279
- this.interventionInput = this.interventionInput.slice(0, -1);
280
- this.render();
281
- return;
282
- }
283
-
284
- if (str && str.length === 1 && !key.ctrl && !key.meta) {
285
- this.interventionInput += str;
286
- this.render();
287
- }
288
- }
289
-
290
- private handleTimeoutKey(str: string, key: any) {
291
- if (key && key.name === 'escape') {
292
- this.view = View.LANE_DETAIL;
293
- this.render();
294
- return;
295
- }
296
-
297
- if (key && (key.name === 'return' || key.name === 'enter')) {
298
- if (this.timeoutInput.trim()) {
299
- this.sendTimeoutUpdate(this.timeoutInput.trim());
300
- }
301
- this.view = View.LANE_DETAIL;
302
- this.render();
303
- return;
304
- }
305
-
306
- if (key && key.name === 'backspace') {
307
- this.timeoutInput = this.timeoutInput.slice(0, -1);
308
- this.render();
309
- return;
310
- }
311
-
312
- // Only allow numbers
313
- if (str && /^\d$/.test(str)) {
314
- this.timeoutInput += str;
315
- this.render();
316
- }
317
- }
318
-
319
- private handleFlowKey(key: string) {
320
- switch (key) {
321
- case 'f':
322
- case 'escape':
323
- case 'backspace':
324
- case 'right':
325
- case 'return':
326
- case 'enter':
327
- case 'left':
328
- this.view = View.LIST;
329
- this.render();
330
- break;
331
- case 'q':
332
- this.stop();
333
- break;
334
- }
335
- }
336
-
337
- private sendIntervention(message: string) {
338
- if (!this.selectedLaneName) return;
339
- const lane = this.lanes.find(l => l.name === this.selectedLaneName);
340
- if (!lane) return;
341
-
342
- try {
343
- const interventionPath = safeJoin(lane.path, 'intervention.txt');
344
- fs.writeFileSync(interventionPath, message, 'utf8');
345
-
346
- // Also log it to the conversation
347
- const convoPath = safeJoin(lane.path, 'conversation.jsonl');
348
- const entry = {
349
- timestamp: new Date().toISOString(),
350
- role: 'user',
351
- task: 'INTERVENTION',
352
- fullText: `[HUMAN INTERVENTION]: ${message}`,
353
- textLength: message.length + 20,
354
- model: 'manual'
355
- };
356
- fs.appendFileSync(convoPath, JSON.stringify(entry) + '\n', 'utf8');
357
-
358
- this.showNotification('Intervention message sent', 'success');
359
- } catch (e) {
360
- this.showNotification('Failed to send intervention', 'error');
361
- }
362
- }
363
-
364
- private sendTimeoutUpdate(timeoutStr: string) {
365
- if (!this.selectedLaneName) return;
366
- const lane = this.lanes.find(l => l.name === this.selectedLaneName);
367
- if (!lane) return;
368
-
369
- try {
370
- const timeoutMs = parseInt(timeoutStr);
371
- if (isNaN(timeoutMs) || timeoutMs <= 0) {
372
- this.showNotification('Invalid timeout value', 'error');
373
- return;
374
- }
375
-
376
- const timeoutPath = safeJoin(lane.path, 'timeout.txt');
377
- fs.writeFileSync(timeoutPath, String(timeoutMs), 'utf8');
378
-
379
- this.showNotification(`Timeout updated to ${Math.round(timeoutMs/1000)}s`, 'success');
380
- } catch (e) {
381
- this.showNotification('Failed to update timeout', 'error');
382
- }
383
- }
384
-
385
- private refreshLogs() {
386
- if (!this.selectedLaneName) return;
387
- const lane = this.lanes.find(l => l.name === this.selectedLaneName);
388
- if (!lane) return;
389
- const convoPath = safeJoin(lane.path, 'conversation.jsonl');
390
- this.currentLogs = readLog<ConversationEntry>(convoPath);
391
- // Keep selection in bounds after refresh
392
- if (this.selectedMessageIndex >= this.currentLogs.length) {
393
- this.selectedMessageIndex = Math.max(0, this.currentLogs.length - 1);
394
- }
395
- }
396
-
397
- private refresh() {
398
- this.lanes = this.listLanesWithDeps(this.runDir);
399
- if (this.view !== View.LIST) {
400
- this.refreshLogs();
401
- }
402
- this.render();
403
- }
404
-
405
- private killLane() {
406
- if (!this.selectedLaneName) return;
407
- const lane = this.lanes.find(l => l.name === this.selectedLaneName);
408
- if (!lane) return;
409
-
410
- const status = this.getLaneStatus(lane.path, lane.name);
411
- if (status.pid && status.status === 'running') {
412
- try {
413
- process.kill(status.pid, 'SIGTERM');
414
- this.showNotification(`Sent SIGTERM to PID ${status.pid}`, 'success');
415
- } catch (e) {
416
- this.showNotification(`Failed to kill PID ${status.pid}`, 'error');
417
- }
418
- } else {
419
- this.showNotification(`No running process found for ${this.selectedLaneName}`, 'info');
420
- }
421
- }
422
-
423
- private showNotification(message: string, type: 'info' | 'error' | 'success') {
424
- this.notification = { message, type, time: Date.now() };
425
- this.render();
426
- }
427
-
428
- private render() {
429
- // Clear screen
430
- process.stdout.write('\x1Bc');
431
-
432
- // Clear old notifications
433
- if (this.notification && Date.now() - this.notification.time > 3000) {
434
- this.notification = null;
435
- }
436
-
437
- if (this.notification) {
438
- const color = this.notification.type === 'error' ? '\x1b[31m' : this.notification.type === 'success' ? '\x1b[32m' : '\x1b[36m';
439
- console.log(`${color}🔔 ${this.notification.message}\x1b[0m\n`);
440
- }
441
-
442
- switch (this.view) {
443
- case View.LIST:
444
- this.renderList();
445
- break;
446
- case View.LANE_DETAIL:
447
- this.renderLaneDetail();
448
- break;
449
- case View.MESSAGE_DETAIL:
450
- this.renderMessageDetail();
451
- break;
452
- case View.FLOW:
453
- this.renderFlow();
454
- break;
455
- case View.TERMINAL:
456
- this.renderTerminal();
457
- break;
458
- case View.INTERVENE:
459
- this.renderIntervene();
460
- break;
461
- case View.TIMEOUT:
462
- this.renderTimeout();
463
- break;
464
- }
465
- }
466
-
467
- private renderList() {
468
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
469
- console.log(`📊 CursorFlow Monitor - Run: ${path.basename(this.runDir)}`);
470
- console.log(`🕒 Updated: ${new Date().toLocaleTimeString()} | [↑/↓/→] Nav [←] Flow [Q] Quit`);
471
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
472
-
473
- if (this.lanes.length === 0) {
474
- console.log(' No lanes found\n');
475
- return;
476
- }
477
-
478
- const laneStatuses: Record<string, any> = {};
479
- this.lanes.forEach(l => laneStatuses[l.name] = this.getLaneStatus(l.path, l.name));
480
-
481
- const maxNameLen = Math.max(...this.lanes.map(l => l.name.length), 15);
482
- console.log(` ${'Lane'.padEnd(maxNameLen)} Status Progress Time Tasks Next Action`);
483
- console.log(` ${'─'.repeat(maxNameLen)} ${'─'.repeat(18)} ${'─'.repeat(8)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(20)}`);
484
-
485
- this.lanes.forEach((lane, i) => {
486
- const isSelected = i === this.selectedLaneIndex;
487
- const status = laneStatuses[lane.name];
488
- const statusIcon = this.getStatusIcon(status.status);
489
- const statusText = `${statusIcon} ${status.status}`.padEnd(18);
490
- const progressText = status.progress.padEnd(8);
491
- const timeText = this.formatDuration(status.duration).padEnd(8);
492
-
493
- let tasksDisplay = '-';
494
- if (typeof status.totalTasks === 'number') {
495
- tasksDisplay = `${status.currentTask}/${status.totalTasks}`;
496
- }
497
- const tasksText = tasksDisplay.padEnd(6);
498
-
499
- // Determine "Next Action"
500
- let nextAction = '-';
501
- if (status.status === 'completed') {
502
- const dependents = this.lanes.filter(l => laneStatuses[l.name].dependsOn.includes(lane.name));
503
- if (dependents.length > 0) {
504
- nextAction = `Unlock: ${dependents.map(d => d.name).join(', ')}`;
505
- } else {
506
- nextAction = '🏁 Done';
507
- }
508
- } else if (status.status === 'waiting') {
509
- if (status.waitingFor && status.waitingFor.length > 0) {
510
- nextAction = `Wait for task: ${status.waitingFor.join(', ')}`;
511
- } else {
512
- const missingDeps = status.dependsOn.filter((d: string) => laneStatuses[d] && laneStatuses[d].status !== 'completed');
513
- nextAction = `Wait for lane: ${missingDeps.join(', ')}`;
514
- }
515
- } else if (status.status === 'running') {
516
- nextAction = '🚀 Working...';
517
- }
518
-
519
- const prefix = isSelected ? ' ▶ ' : ' ';
520
- const line = `${prefix}${lane.name.padEnd(maxNameLen)} ${statusText} ${progressText} ${timeText} ${tasksText} ${nextAction}`;
521
-
522
- if (isSelected) {
523
- process.stdout.write(`\x1b[36m${line}\x1b[0m\n`);
524
- } else {
525
- process.stdout.write(`${line}\n`);
526
- }
527
- });
528
-
529
- console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
530
- }
531
-
532
- private renderLaneDetail() {
533
- const lane = this.lanes.find(l => l.name === this.selectedLaneName);
534
- if (!lane) {
535
- this.view = View.LIST;
536
- this.render();
537
- return;
538
- }
539
-
540
- const status = this.getLaneStatus(lane.path, lane.name);
541
- const logPath = safeJoin(lane.path, 'terminal.log');
542
- let liveLog = '(No live terminal output)';
543
- if (fs.existsSync(logPath)) {
544
- const content = fs.readFileSync(logPath, 'utf8');
545
- liveLog = content.split('\n').slice(-15).join('\n');
546
- }
547
-
548
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
549
- console.log(`🔍 Lane: ${lane.name}`);
550
- console.log(`🕒 Updated: ${new Date().toLocaleTimeString()} | [↑/↓] Browse [T] Term [I] Intervene [O] Timeout [Esc] Back`);
551
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
552
-
553
- process.stdout.write(` Status: ${this.getStatusIcon(status.status)} ${status.status}\n`);
554
- process.stdout.write(` PID: ${status.pid || '-'}\n`);
555
- process.stdout.write(` Progress: ${status.progress} (${status.currentTask}/${status.totalTasks} tasks)\n`);
556
- process.stdout.write(` Time: ${this.formatDuration(status.duration)}\n`);
557
- process.stdout.write(` Branch: ${status.pipelineBranch}\n`);
558
- process.stdout.write(` Chat ID: ${status.chatId}\n`);
559
- process.stdout.write(` Depends: ${status.dependsOn.join(', ') || 'None'}\n`);
560
-
561
- if (status.waitingFor && status.waitingFor.length > 0) {
562
- process.stdout.write(`\x1b[33m Wait For: ${status.waitingFor.join(', ')}\x1b[0m\n`);
563
- }
564
-
565
- if (status.error) {
566
- process.stdout.write(`\x1b[31m Error: ${status.error}\x1b[0m\n`);
567
- }
568
-
569
- console.log('\n🖥️ Live Terminal Output (Last 15 lines):');
570
- console.log('─'.repeat(80));
571
- console.log(`\x1b[90m${liveLog}\x1b[0m`);
572
-
573
- console.log('\n💬 Conversation History (Select to see full details):');
574
- console.log('─'.repeat(80));
575
- process.stdout.write(' [↑/↓] Browse | [→/Enter] Full Msg | [I] Intervene | [K] Kill | [T] Live Terminal | [Esc/←] Back\n\n');
576
-
577
- if (this.currentLogs.length === 0) {
578
- console.log(' (No messages yet)');
579
- } else {
580
- // Simple windowed view for long histories
581
- const maxVisible = 15; // Number of messages to show
582
- if (this.selectedMessageIndex < this.scrollOffset) {
583
- this.scrollOffset = this.selectedMessageIndex;
584
- } else if (this.selectedMessageIndex >= this.scrollOffset + maxVisible) {
585
- this.scrollOffset = this.selectedMessageIndex - maxVisible + 1;
586
- }
587
-
588
- const visibleLogs = this.currentLogs.slice(this.scrollOffset, this.scrollOffset + maxVisible);
589
-
590
- visibleLogs.forEach((log, i) => {
591
- const actualIndex = i + this.scrollOffset;
592
- const isSelected = actualIndex === this.selectedMessageIndex;
593
- const roleColor = log.role === 'user' ? '\x1b[33m' : log.role === 'reviewer' ? '\x1b[35m' : '\x1b[32m';
594
- const role = log.role.toUpperCase().padEnd(10);
595
-
596
- const prefix = isSelected ? '▶ ' : ' ';
597
- const header = `${prefix}${roleColor}${role}\x1b[0m [${new Date(log.timestamp).toLocaleTimeString()}]`;
598
-
599
- if (isSelected) {
600
- process.stdout.write(`\x1b[48;5;236m${header}\x1b[0m\n`);
601
- } else {
602
- process.stdout.write(`${header}\n`);
603
- }
604
-
605
- const lines = log.fullText.split('\n').filter(l => l.trim());
606
- const preview = lines[0]?.substring(0, 70) || '...';
607
- process.stdout.write(` ${preview}${log.fullText.length > 70 ? '...' : ''}\n\n`);
608
- });
609
-
610
- if (this.currentLogs.length > maxVisible) {
611
- console.log(` -- (${this.currentLogs.length - maxVisible} more messages, use ↑/↓ to scroll) --`);
612
- }
613
- }
614
-
615
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
616
- }
617
-
618
- private renderMessageDetail() {
619
- const log = this.currentLogs[this.selectedMessageIndex];
620
- if (!log) {
621
- this.view = View.LANE_DETAIL;
622
- this.render();
623
- return;
624
- }
625
-
626
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
627
- console.log(`📄 Full Message Detail - ${log.role.toUpperCase()}`);
628
- console.log(`🕒 ${new Date(log.timestamp).toLocaleString()} | [Esc/←] Back to History`);
629
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
630
-
631
- const roleColor = log.role === 'user' ? '\x1b[33m' : log.role === 'reviewer' ? '\x1b[35m' : '\x1b[32m';
632
- process.stdout.write(`${roleColor}ROLE: ${log.role.toUpperCase()}\x1b[0m\n`);
633
- if (log.model) process.stdout.write(`MODEL: ${log.model}\n`);
634
- if (log.task) process.stdout.write(`TASK: ${log.task}\n`);
635
- console.log('─'.repeat(40));
636
- console.log(log.fullText);
637
- console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
638
- }
639
-
640
- private renderFlow() {
641
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
642
- console.log(`⛓️ Task Dependency Flow`);
643
- console.log(`🕒 Updated: ${new Date().toLocaleTimeString()} | [→/Enter/Esc] Back to List`);
644
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
645
-
646
- const laneMap = new Map<string, any>();
647
- this.lanes.forEach(lane => {
648
- laneMap.set(lane.name, this.getLaneStatus(lane.path, lane.name));
649
- });
650
-
651
- // Enhanced visualization with box-like structure and clear connections
652
- this.lanes.forEach(lane => {
653
- const status = laneMap.get(lane.name);
654
- const statusIcon = this.getStatusIcon(status.status);
655
-
656
- let statusColor = '\x1b[90m'; // Grey for pending/waiting
657
- if (status.status === 'completed') statusColor = '\x1b[32m'; // Green
658
- if (status.status === 'running') statusColor = '\x1b[36m'; // Cyan
659
- if (status.status === 'failed') statusColor = '\x1b[31m'; // Red
660
-
661
- // Render the node
662
- const nodeText = `[ ${statusIcon} ${lane.name.padEnd(18)} ]`;
663
- process.stdout.write(` ${statusColor}${nodeText}\x1b[0m`);
664
-
665
- // Render dependencies
666
- if (status.dependsOn && status.dependsOn.length > 0) {
667
- process.stdout.write(` \x1b[90m◀───\x1b[0m \x1b[33m( ${status.dependsOn.join(', ')} )\x1b[0m`);
668
- }
669
- process.stdout.write('\n');
670
- });
671
-
672
- console.log('\n\x1b[90m (Lanes wait for their dependencies to complete before starting)\x1b[0m');
673
- console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
674
- }
675
-
676
- private renderTerminal() {
677
- const lane = this.lanes.find(l => l.name === this.selectedLaneName);
678
- if (!lane) {
679
- this.view = View.LIST;
680
- this.render();
681
- return;
682
- }
683
-
684
- const logPath = safeJoin(lane.path, 'terminal.log');
685
- let logLines: string[] = [];
686
- if (fs.existsSync(logPath)) {
687
- const content = fs.readFileSync(logPath, 'utf8');
688
- logLines = content.split('\n');
689
- }
690
-
691
- const maxVisible = 40;
692
- const totalLines = logLines.length;
693
-
694
- // Sticky scroll logic: if new lines arrived and we are already scrolled up,
695
- // increase the offset to stay on the same content.
696
- if (this.terminalScrollOffset > 0 && this.lastTerminalTotalLines > 0 && totalLines > this.lastTerminalTotalLines) {
697
- this.terminalScrollOffset += (totalLines - this.lastTerminalTotalLines);
698
- }
699
- this.lastTerminalTotalLines = totalLines;
700
-
701
- // Clamp scroll offset
702
- const maxScroll = Math.max(0, totalLines - maxVisible);
703
- if (this.terminalScrollOffset > maxScroll) {
704
- this.terminalScrollOffset = maxScroll;
705
- }
706
-
707
- // Slice based on scroll (0 means bottom, >0 means scrolled up)
708
- const end = totalLines - this.terminalScrollOffset;
709
- const start = Math.max(0, end - maxVisible);
710
- const visibleLines = logLines.slice(start, end);
711
-
712
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
713
- console.log(`🖥️ Full Live Terminal: ${lane.name}`);
714
- console.log(`🕒 Streaming... | [↑/↓] Scroll (${this.terminalScrollOffset > 0 ? `Scrolled Up ${this.terminalScrollOffset}` : 'Bottom'}) | [I] Intervene | [T/Esc/←] Back`);
715
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
716
-
717
- visibleLines.forEach(line => {
718
- let formattedLine = line;
719
- // Highlight human intervention
720
- if (line.includes('[HUMAN INTERVENTION]') || line.includes('Injecting intervention:')) {
721
- formattedLine = `\x1b[33m\x1b[1m${line}\x1b[0m`;
722
- }
723
- // Highlight agent execution starts
724
- else if (line.includes('Executing cursor-agent')) {
725
- formattedLine = `\x1b[36m\x1b[1m${line}\x1b[0m`;
726
- }
727
- // Highlight task headers
728
- else if (line.includes('=== Task:')) {
729
- formattedLine = `\x1b[32m\x1b[1m${line}\x1b[0m`;
730
- }
731
- // Highlight errors
732
- else if (line.toLowerCase().includes('error') || line.toLowerCase().includes('failed')) {
733
- formattedLine = `\x1b[31m${line}\x1b[0m`;
734
- }
735
-
736
- process.stdout.write(` ${formattedLine}\n`);
737
- });
738
-
739
- console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
740
- }
741
-
742
- private renderIntervene() {
743
- console.clear();
744
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
745
- console.log(`🙋 HUMAN INTERVENTION: ${this.selectedLaneName}`);
746
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
747
- console.log('\n Type your message to the agent. This will be sent as a direct prompt.');
748
- console.log(` Press \x1b[1mENTER\x1b[0m to send, \x1b[1mESC\x1b[0m to cancel.\n`);
749
- console.log(`\x1b[33m > \x1b[0m${this.interventionInput}\x1b[37m█\x1b[0m`);
750
- console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
751
- }
752
-
753
- private renderTimeout() {
754
- console.clear();
755
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
756
- console.log(`⏱ UPDATE TIMEOUT: ${this.selectedLaneName}`);
757
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
758
- console.log('\n Enter new timeout in milliseconds (e.g., 600000 for 10 minutes).');
759
- console.log(` Press \x1b[1mENTER\x1b[0m to apply, \x1b[1mESC\x1b[0m to cancel.\n`);
760
- console.log(`\x1b[33m > \x1b[0m${this.timeoutInput}\x1b[37m█\x1b[0m`);
761
- console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
762
- }
763
-
764
- private listLanesWithDeps(runDir: string): LaneWithDeps[] {
765
- const lanesDir = safeJoin(runDir, 'lanes');
766
- if (!fs.existsSync(lanesDir)) return [];
767
-
768
- const config = loadConfig();
769
- const tasksDir = safeJoin(config.projectRoot, config.tasksDir);
770
-
771
- const laneConfigs = this.listLaneFilesFromDir(tasksDir);
772
-
773
- return fs.readdirSync(lanesDir)
774
- .filter(d => fs.statSync(safeJoin(lanesDir, d)).isDirectory())
775
- .map(name => {
776
- const config = laneConfigs.find(c => c.name === name);
777
- return {
778
- name,
779
- path: safeJoin(lanesDir, name),
780
- dependsOn: config?.dependsOn || [],
781
- };
782
- });
783
- }
784
-
785
- private listLaneFilesFromDir(tasksDir: string): { name: string; dependsOn: string[] }[] {
786
- if (!fs.existsSync(tasksDir)) return [];
787
- return fs.readdirSync(tasksDir)
788
- .filter(f => f.endsWith('.json'))
789
- .map(f => {
790
- const filePath = safeJoin(tasksDir, f);
791
- try {
792
- const config = JSON.parse(fs.readFileSync(filePath, 'utf8'));
793
- return { name: path.basename(f, '.json'), dependsOn: config.dependsOn || [] };
794
- } catch {
795
- return { name: path.basename(f, '.json'), dependsOn: [] };
796
- }
797
- });
798
- }
799
-
800
- private getLaneStatus(lanePath: string, laneName: string) {
801
- const statePath = safeJoin(lanePath, 'state.json');
802
- const state = loadState<LaneState & { chatId?: string }>(statePath);
803
-
804
- const laneInfo = this.lanes.find(l => l.name === laneName);
805
- const dependsOn = state?.dependsOn || laneInfo?.dependsOn || [];
806
-
807
- if (!state) {
808
- return { status: 'pending', currentTask: 0, totalTasks: '?', progress: '0%', dependsOn, duration: 0, pipelineBranch: '-', chatId: '-' };
809
- }
810
-
811
- const progress = state.totalTasks > 0 ? Math.round((state.currentTaskIndex / state.totalTasks) * 100) : 0;
812
-
813
- const duration = state.startTime ? (state.endTime
814
- ? state.endTime - state.startTime
815
- : (state.status === 'running' || state.status === 'reviewing' ? Date.now() - state.startTime : 0)) : 0;
816
-
817
- return {
818
- status: state.status || 'unknown',
819
- currentTask: state.currentTaskIndex || 0,
820
- totalTasks: state.totalTasks || '?',
821
- progress: `${progress}%`,
822
- pipelineBranch: state.pipelineBranch || '-',
823
- chatId: state.chatId || '-',
824
- dependsOn,
825
- duration,
826
- error: state.error,
827
- pid: state.pid,
828
- waitingFor: state.waitingFor || [],
829
- };
830
- }
831
-
832
- private formatDuration(ms: number): string {
833
- if (ms <= 0) return '-';
834
- const seconds = Math.floor((ms / 1000) % 60);
835
- const minutes = Math.floor((ms / (1000 * 60)) % 60);
836
- const hours = Math.floor(ms / (1000 * 60 * 60));
837
-
838
- if (hours > 0) return `${hours}h ${minutes}m`;
839
- if (minutes > 0) return `${minutes}m ${seconds}s`;
840
- return `${seconds}s`;
841
- }
842
-
843
- private getStatusIcon(status: string): string {
844
- const icons: Record<string, string> = {
845
- 'running': '🔄',
846
- 'waiting': '⏳',
847
- 'completed': '✅',
848
- 'failed': '',
849
- 'blocked_dependency': '🚫',
850
- 'pending': '⚪',
851
- 'reviewing': '👀',
852
- };
853
- return icons[status] || '❓';
854
- }
855
- }
856
-
857
- /**
858
- * Find the latest run directory
859
- */
860
- function findLatestRunDir(logsDir: string): string | null {
861
- const runsDir = safeJoin(logsDir, 'runs');
862
- if (!fs.existsSync(runsDir)) return null;
863
- const runs = fs.readdirSync(runsDir)
864
- .filter(d => d.startsWith('run-'))
865
- .map(d => ({ name: d, path: safeJoin(runsDir, d), mtime: fs.statSync(safeJoin(runsDir, d)).mtime.getTime() }))
866
- .sort((a, b) => b.mtime - a.mtime);
867
- return runs.length > 0 ? runs[0]!.path : null;
868
- }
869
-
870
- /**
871
- * Monitor lanes
872
- */
873
- async function monitor(args: string[]): Promise<void> {
874
- const help = args.includes('--help') || args.includes('-h');
875
- if (help) {
876
- printHelp();
877
- return;
878
- }
879
-
880
- const watchIdx = args.indexOf('--watch');
881
- const intervalIdx = args.indexOf('--interval');
882
- const interval = intervalIdx >= 0 ? parseInt(args[intervalIdx + 1] || '2') || 2 : 2;
883
-
884
- const runDirArg = args.find(arg => !arg.startsWith('--') && args.indexOf(arg) !== intervalIdx + 1);
885
- const config = loadConfig();
886
-
887
- let runDir = runDirArg;
888
- if (!runDir || runDir === 'latest') {
889
- runDir = findLatestRunDir(config.logsDir) || undefined;
890
- if (!runDir) throw new Error('No run directories found');
891
- }
892
-
893
- if (!fs.existsSync(runDir)) throw new Error(`Run directory not found: ${runDir}`);
894
-
895
- const monitor = new InteractiveMonitor(runDir, interval);
896
- await monitor.start();
897
- }
898
-
899
- export = monitor;
1
+ /**
2
+ * CursorFlow interactive monitor command
3
+ *
4
+ * Features:
5
+ * - Lane dashboard with accurate process status
6
+ * - Unified log view for all lanes
7
+ * - Readable log format support
8
+ * - Multiple flows dashboard
9
+ * - Consistent layout across all views
10
+ */
11
+
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import * as readline from 'readline';
15
+ import { loadState, readLog } from '../utils/state';
16
+ import { LaneState, ConversationEntry } from '../utils/types';
17
+ import { loadConfig } from '../utils/config';
18
+ import { safeJoin } from '../utils/path';
19
+ import { getLaneProcessStatus, getFlowSummary, LaneProcessStatus } from '../services/process';
20
+ import { LogBufferService, BufferedLogEntry } from '../services/logging/buffer';
21
+ import { formatReadableEntry, formatMessageForConsole, stripAnsi } from '../services/logging/formatter';
22
+ import { MessageType } from '../types/logging';
23
+
24
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
25
+ // UI Constants
26
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
27
+ const UI = {
28
+ COLORS: {
29
+ reset: '\x1b[0m',
30
+ bold: '\x1b[1m',
31
+ dim: '\x1b[2m',
32
+ cyan: '\x1b[36m',
33
+ green: '\x1b[32m',
34
+ yellow: '\x1b[33m',
35
+ red: '\x1b[31m',
36
+ magenta: '\x1b[35m',
37
+ gray: '\x1b[90m',
38
+ white: '\x1b[37m',
39
+ bgGray: '\x1b[48;5;236m',
40
+ bgCyan: '\x1b[46m',
41
+ },
42
+ CHARS: {
43
+ hLine: '━',
44
+ vLine: '│',
45
+ corner: {
46
+ tl: '┌', tr: '┐', bl: '└', br: '┘'
47
+ },
48
+ arrow: {
49
+ right: '▶', left: '◀', up: '▲', down: '▼'
50
+ },
51
+ bullet: '•',
52
+ check: '✓',
53
+ },
54
+ };
55
+
56
+ interface LaneWithDeps {
57
+ name: string;
58
+ path: string;
59
+ dependsOn: string[];
60
+ }
61
+
62
+ interface MonitorOptions {
63
+ runDir?: string;
64
+ interval: number;
65
+ help: boolean;
66
+ }
67
+
68
+ function printHelp(): void {
69
+ console.log(`
70
+ Usage: cursorflow monitor [run-dir] [options]
71
+
72
+ Interactive lane dashboard to track progress and dependencies.
73
+
74
+ Options:
75
+ [run-dir] Run directory to monitor (default: latest)
76
+ --interval <seconds> Refresh interval (default: 2)
77
+ --help, -h Show help
78
+ `);
79
+ }
80
+
81
+ enum View {
82
+ LIST,
83
+ LANE_DETAIL,
84
+ MESSAGE_DETAIL,
85
+ FLOW,
86
+ TERMINAL,
87
+ INTERVENE,
88
+ TIMEOUT,
89
+ UNIFIED_LOG,
90
+ FLOWS_DASHBOARD
91
+ }
92
+
93
+ class InteractiveMonitor {
94
+ private runDir: string;
95
+ private interval: number;
96
+ private view: View = View.LIST;
97
+ private selectedLaneIndex: number = 0;
98
+ private selectedMessageIndex: number = 0;
99
+ private selectedLaneName: string | null = null;
100
+ private lanes: LaneWithDeps[] = [];
101
+ private currentLogs: ConversationEntry[] = [];
102
+ private timer: NodeJS.Timeout | null = null;
103
+ private scrollOffset: number = 0;
104
+ private terminalScrollOffset: number = 0;
105
+ private followMode: boolean = true;
106
+ private unseenLineCount: number = 0;
107
+ private lastTerminalTotalLines: number = 0;
108
+ private interventionInput: string = '';
109
+ private timeoutInput: string = '';
110
+ private notification: { message: string; type: 'info' | 'error' | 'success'; time: number } | null = null;
111
+
112
+ // Process status tracking
113
+ private laneProcessStatuses: Map<string, LaneProcessStatus> = new Map();
114
+
115
+ // Unified log buffer for all lanes
116
+ private unifiedLogBuffer: LogBufferService | null = null;
117
+ private unifiedLogScrollOffset: number = 0;
118
+ private unifiedLogFollowMode: boolean = true;
119
+
120
+ // Multiple flows support
121
+ private allFlows: { runDir: string; runId: string; isAlive: boolean; summary: ReturnType<typeof getFlowSummary> }[] = [];
122
+ private selectedFlowIndex: number = 0;
123
+ private logsDir: string = '';
124
+
125
+ // NEW: UX improvements
126
+ private readableFormat: boolean = true; // Toggle readable log format
127
+ private laneFilter: string | null = null; // Filter by lane name
128
+ private confirmAction: { type: 'delete-flow' | 'kill-lane'; target: string; time: number } | null = null;
129
+
130
+ // Screen dimensions
131
+ private get screenWidth(): number {
132
+ return process.stdout.columns || 120;
133
+ }
134
+ private get screenHeight(): number {
135
+ return process.stdout.rows || 24;
136
+ }
137
+
138
+ constructor(runDir: string, interval: number, logsDir?: string) {
139
+ this.runDir = runDir;
140
+ this.interval = interval;
141
+
142
+ // Set logs directory for multiple flows discovery
143
+ if (logsDir) {
144
+ this.logsDir = logsDir;
145
+ } else {
146
+ const config = loadConfig();
147
+ this.logsDir = safeJoin(config.logsDir, 'runs');
148
+ }
149
+
150
+ // Initialize unified log buffer
151
+ this.unifiedLogBuffer = new LogBufferService(runDir);
152
+ }
153
+
154
+ public async start() {
155
+ this.setupTerminal();
156
+
157
+ // Start unified log streaming
158
+ if (this.unifiedLogBuffer) {
159
+ this.unifiedLogBuffer.startStreaming();
160
+ this.unifiedLogBuffer.on('update', () => {
161
+ if (this.view === View.UNIFIED_LOG && this.unifiedLogFollowMode) {
162
+ this.render();
163
+ }
164
+ });
165
+ }
166
+
167
+ // Discover all flows
168
+ this.discoverFlows();
169
+
170
+ this.refresh();
171
+ this.timer = setInterval(() => this.refresh(), this.interval * 1000);
172
+ }
173
+
174
+ /**
175
+ * Discover all run directories (flows) for multi-flow view
176
+ */
177
+ private discoverFlows(): void {
178
+ try {
179
+ if (!fs.existsSync(this.logsDir)) return;
180
+
181
+ const runs = fs.readdirSync(this.logsDir)
182
+ .filter(d => d.startsWith('run-'))
183
+ .map(d => {
184
+ const runDir = safeJoin(this.logsDir, d);
185
+ const summary = getFlowSummary(runDir);
186
+ return {
187
+ runDir,
188
+ runId: d,
189
+ isAlive: summary.isAlive,
190
+ summary,
191
+ };
192
+ })
193
+ .sort((a, b) => {
194
+ // Sort by run ID (timestamp-based) descending
195
+ return b.runId.localeCompare(a.runId);
196
+ });
197
+
198
+ this.allFlows = runs;
199
+ } catch {
200
+ // Ignore errors
201
+ }
202
+ }
203
+
204
+ private setupTerminal() {
205
+ if (process.stdin.isTTY) {
206
+ process.stdin.setRawMode(true);
207
+ }
208
+ readline.emitKeypressEvents(process.stdin);
209
+ process.stdin.on('keypress', (str, key) => {
210
+ // Handle Ctrl+C
211
+ if (key && key.ctrl && key.name === 'c') {
212
+ this.stop();
213
+ return;
214
+ }
215
+
216
+ // Safeguard against missing key object
217
+ const keyName = key ? key.name : str;
218
+
219
+ if (this.view === View.LIST) {
220
+ this.handleListKey(keyName);
221
+ } else if (this.view === View.LANE_DETAIL) {
222
+ this.handleDetailKey(keyName);
223
+ } else if (this.view === View.FLOW) {
224
+ this.handleFlowKey(keyName);
225
+ } else if (this.view === View.TERMINAL) {
226
+ this.handleTerminalKey(keyName);
227
+ } else if (this.view === View.INTERVENE) {
228
+ this.handleInterveneKey(str, key);
229
+ } else if (this.view === View.TIMEOUT) {
230
+ this.handleTimeoutKey(str, key);
231
+ } else if (this.view === View.MESSAGE_DETAIL) {
232
+ this.handleMessageDetailKey(keyName);
233
+ } else if (this.view === View.UNIFIED_LOG) {
234
+ this.handleUnifiedLogKey(keyName);
235
+ } else if (this.view === View.FLOWS_DASHBOARD) {
236
+ this.handleFlowsDashboardKey(keyName);
237
+ }
238
+ });
239
+
240
+ // Hide cursor
241
+ process.stdout.write('\x1B[?25l');
242
+ }
243
+
244
+ private stop() {
245
+ if (this.timer) clearInterval(this.timer);
246
+
247
+ // Stop unified log streaming
248
+ if (this.unifiedLogBuffer) {
249
+ this.unifiedLogBuffer.stopStreaming();
250
+ }
251
+
252
+ // Show cursor and clear screen
253
+ process.stdout.write('\x1B[?25h');
254
+ process.stdout.write('\x1Bc');
255
+ console.log('\n👋 Monitoring stopped\n');
256
+ process.exit(0);
257
+ }
258
+
259
+ private handleListKey(key: string) {
260
+ switch (key) {
261
+ case 'up':
262
+ this.selectedLaneIndex = Math.max(0, this.selectedLaneIndex - 1);
263
+ this.render();
264
+ break;
265
+ case 'down':
266
+ this.selectedLaneIndex = Math.min(this.lanes.length - 1, this.selectedLaneIndex + 1);
267
+ this.render();
268
+ break;
269
+ case 'right':
270
+ case 'return':
271
+ case 'enter':
272
+ if (this.lanes[this.selectedLaneIndex]) {
273
+ this.selectedLaneName = this.lanes[this.selectedLaneIndex]!.name;
274
+ this.view = View.LANE_DETAIL;
275
+ this.selectedMessageIndex = 0;
276
+ this.scrollOffset = 0;
277
+ this.refreshLogs();
278
+ this.render();
279
+ }
280
+ break;
281
+ case 'left':
282
+ case 'f':
283
+ this.view = View.FLOW;
284
+ this.render();
285
+ break;
286
+ case 'u':
287
+ // Unified log view
288
+ this.view = View.UNIFIED_LOG;
289
+ this.unifiedLogScrollOffset = 0;
290
+ this.unifiedLogFollowMode = true;
291
+ this.render();
292
+ break;
293
+ case 'm':
294
+ // Multiple flows dashboard
295
+ this.discoverFlows();
296
+ this.view = View.FLOWS_DASHBOARD;
297
+ this.render();
298
+ break;
299
+ case 'q':
300
+ this.stop();
301
+ break;
302
+ }
303
+ }
304
+
305
+ private handleDetailKey(key: string) {
306
+ switch (key) {
307
+ case 'up':
308
+ this.selectedMessageIndex = Math.max(0, this.selectedMessageIndex - 1);
309
+ this.render();
310
+ break;
311
+ case 'down':
312
+ this.selectedMessageIndex = Math.min(this.currentLogs.length - 1, this.selectedMessageIndex + 1);
313
+ this.render();
314
+ break;
315
+ case 'right':
316
+ case 'return':
317
+ case 'enter':
318
+ if (this.currentLogs[this.selectedMessageIndex]) {
319
+ this.view = View.MESSAGE_DETAIL;
320
+ this.render();
321
+ }
322
+ break;
323
+ case 't':
324
+ this.view = View.TERMINAL;
325
+ this.terminalScrollOffset = 0;
326
+ this.render();
327
+ break;
328
+ case 'k':
329
+ this.killLane();
330
+ break;
331
+ case 'i':
332
+ const lane = this.lanes.find(l => l.name === this.selectedLaneName);
333
+ if (lane) {
334
+ const status = this.getLaneStatus(lane.path, lane.name);
335
+ if (status.status === 'running') {
336
+ this.view = View.INTERVENE;
337
+ this.interventionInput = '';
338
+ this.render();
339
+ } else {
340
+ this.showNotification('Intervention only available for RUNNING lanes', 'error');
341
+ }
342
+ }
343
+ break;
344
+ case 'o':
345
+ const timeoutLane = this.lanes.find(l => l.name === this.selectedLaneName);
346
+ if (timeoutLane) {
347
+ const status = this.getLaneStatus(timeoutLane.path, timeoutLane.name);
348
+ if (status.status === 'running') {
349
+ this.view = View.TIMEOUT;
350
+ this.timeoutInput = '';
351
+ this.render();
352
+ } else {
353
+ this.showNotification('Timeout update only available for RUNNING lanes', 'error');
354
+ }
355
+ }
356
+ break;
357
+ case 'escape':
358
+ case 'backspace':
359
+ case 'left':
360
+ this.view = View.LIST;
361
+ this.selectedLaneName = null;
362
+ this.render();
363
+ break;
364
+ case 'q':
365
+ this.stop();
366
+ break;
367
+ }
368
+ }
369
+
370
+ private handleMessageDetailKey(key: string) {
371
+ switch (key) {
372
+ case 'escape':
373
+ case 'backspace':
374
+ case 'left':
375
+ this.view = View.LANE_DETAIL;
376
+ this.render();
377
+ break;
378
+ case 'q':
379
+ this.stop();
380
+ break;
381
+ }
382
+ }
383
+
384
+ private handleTerminalKey(key: string) {
385
+ switch (key) {
386
+ case 'up':
387
+ this.followMode = false;
388
+ this.terminalScrollOffset++;
389
+ this.render();
390
+ break;
391
+ case 'down':
392
+ this.terminalScrollOffset = Math.max(0, this.terminalScrollOffset - 1);
393
+ if (this.terminalScrollOffset === 0) {
394
+ this.followMode = true;
395
+ this.unseenLineCount = 0;
396
+ }
397
+ this.render();
398
+ break;
399
+ case 'f':
400
+ this.followMode = true;
401
+ this.terminalScrollOffset = 0;
402
+ this.unseenLineCount = 0;
403
+ this.render();
404
+ break;
405
+ case 'r':
406
+ // Toggle readable log format
407
+ this.readableFormat = !this.readableFormat;
408
+ this.terminalScrollOffset = 0;
409
+ this.lastTerminalTotalLines = 0;
410
+ this.render();
411
+ break;
412
+ case 't':
413
+ case 'escape':
414
+ case 'backspace':
415
+ case 'left':
416
+ this.view = View.LANE_DETAIL;
417
+ this.render();
418
+ break;
419
+ case 'i':
420
+ this.view = View.INTERVENE;
421
+ this.interventionInput = '';
422
+ this.render();
423
+ break;
424
+ case 'q':
425
+ this.stop();
426
+ break;
427
+ }
428
+ }
429
+
430
+ private handleInterveneKey(str: string, key: any) {
431
+ if (key && key.name === 'escape') {
432
+ this.view = View.LANE_DETAIL;
433
+ this.render();
434
+ return;
435
+ }
436
+
437
+ if (key && (key.name === 'return' || key.name === 'enter')) {
438
+ if (this.interventionInput.trim()) {
439
+ this.sendIntervention(this.interventionInput.trim());
440
+ }
441
+ this.view = View.LANE_DETAIL;
442
+ this.render();
443
+ return;
444
+ }
445
+
446
+ if (key && key.name === 'backspace') {
447
+ this.interventionInput = this.interventionInput.slice(0, -1);
448
+ this.render();
449
+ return;
450
+ }
451
+
452
+ if (str && str.length === 1 && !key.ctrl && !key.meta) {
453
+ this.interventionInput += str;
454
+ this.render();
455
+ }
456
+ }
457
+
458
+ private handleTimeoutKey(str: string, key: any) {
459
+ if (key && key.name === 'escape') {
460
+ this.view = View.LANE_DETAIL;
461
+ this.render();
462
+ return;
463
+ }
464
+
465
+ if (key && (key.name === 'return' || key.name === 'enter')) {
466
+ if (this.timeoutInput.trim()) {
467
+ this.sendTimeoutUpdate(this.timeoutInput.trim());
468
+ }
469
+ this.view = View.LANE_DETAIL;
470
+ this.render();
471
+ return;
472
+ }
473
+
474
+ if (key && key.name === 'backspace') {
475
+ this.timeoutInput = this.timeoutInput.slice(0, -1);
476
+ this.render();
477
+ return;
478
+ }
479
+
480
+ // Only allow numbers
481
+ if (str && /^\d$/.test(str)) {
482
+ this.timeoutInput += str;
483
+ this.render();
484
+ }
485
+ }
486
+
487
+ private handleFlowKey(key: string) {
488
+ switch (key) {
489
+ case 'f':
490
+ case 'escape':
491
+ case 'backspace':
492
+ case 'right':
493
+ case 'return':
494
+ case 'enter':
495
+ case 'left':
496
+ this.view = View.LIST;
497
+ this.render();
498
+ break;
499
+ case 'q':
500
+ this.stop();
501
+ break;
502
+ }
503
+ }
504
+
505
+ private handleUnifiedLogKey(key: string) {
506
+ const pageSize = Math.max(10, this.screenHeight - 12);
507
+
508
+ switch (key) {
509
+ case 'up':
510
+ this.unifiedLogFollowMode = false;
511
+ this.unifiedLogScrollOffset++;
512
+ this.render();
513
+ break;
514
+ case 'down':
515
+ this.unifiedLogScrollOffset = Math.max(0, this.unifiedLogScrollOffset - 1);
516
+ if (this.unifiedLogScrollOffset === 0) {
517
+ this.unifiedLogFollowMode = true;
518
+ }
519
+ this.render();
520
+ break;
521
+ case 'pageup':
522
+ this.unifiedLogFollowMode = false;
523
+ this.unifiedLogScrollOffset += pageSize;
524
+ this.render();
525
+ break;
526
+ case 'pagedown':
527
+ this.unifiedLogScrollOffset = Math.max(0, this.unifiedLogScrollOffset - pageSize);
528
+ if (this.unifiedLogScrollOffset === 0) {
529
+ this.unifiedLogFollowMode = true;
530
+ }
531
+ this.render();
532
+ break;
533
+ case 'f':
534
+ this.unifiedLogFollowMode = true;
535
+ this.unifiedLogScrollOffset = 0;
536
+ this.render();
537
+ break;
538
+ case 'r':
539
+ // Toggle readable format
540
+ this.readableFormat = !this.readableFormat;
541
+ this.render();
542
+ break;
543
+ case 'l':
544
+ // Cycle through lane filter
545
+ this.cycleLaneFilter();
546
+ this.unifiedLogScrollOffset = 0;
547
+ this.render();
548
+ break;
549
+ case 'escape':
550
+ case 'backspace':
551
+ case 'u':
552
+ this.view = View.LIST;
553
+ this.render();
554
+ break;
555
+ case 'q':
556
+ this.stop();
557
+ break;
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Cycle through available lanes for filtering
563
+ */
564
+ private cycleLaneFilter(): void {
565
+ const lanes = this.unifiedLogBuffer?.getLanes() || [];
566
+ if (lanes.length === 0) {
567
+ this.laneFilter = null;
568
+ return;
569
+ }
570
+
571
+ if (this.laneFilter === null) {
572
+ // Show first lane
573
+ this.laneFilter = lanes[0]!;
574
+ } else {
575
+ const currentIndex = lanes.indexOf(this.laneFilter);
576
+ if (currentIndex === -1 || currentIndex === lanes.length - 1) {
577
+ // Reset to all lanes
578
+ this.laneFilter = null;
579
+ } else {
580
+ // Next lane
581
+ this.laneFilter = lanes[currentIndex + 1]!;
582
+ }
583
+ }
584
+ }
585
+
586
+ private handleFlowsDashboardKey(key: string) {
587
+ // Handle confirmation dialog first
588
+ if (this.confirmAction) {
589
+ if (key === 'y') {
590
+ this.executeConfirmedAction();
591
+ return;
592
+ } else if (key === 'n' || key === 'escape') {
593
+ this.confirmAction = null;
594
+ this.render();
595
+ return;
596
+ }
597
+ // Other keys cancel confirmation
598
+ this.confirmAction = null;
599
+ this.render();
600
+ return;
601
+ }
602
+
603
+ switch (key) {
604
+ case 'up':
605
+ this.selectedFlowIndex = Math.max(0, this.selectedFlowIndex - 1);
606
+ this.render();
607
+ break;
608
+ case 'down':
609
+ this.selectedFlowIndex = Math.min(this.allFlows.length - 1, this.selectedFlowIndex + 1);
610
+ this.render();
611
+ break;
612
+ case 'right':
613
+ case 'return':
614
+ case 'enter':
615
+ // Switch to selected flow
616
+ if (this.allFlows[this.selectedFlowIndex]) {
617
+ const flow = this.allFlows[this.selectedFlowIndex]!;
618
+ this.runDir = flow.runDir;
619
+
620
+ // Restart log buffer for new run
621
+ if (this.unifiedLogBuffer) {
622
+ this.unifiedLogBuffer.stopStreaming();
623
+ }
624
+ this.unifiedLogBuffer = new LogBufferService(this.runDir);
625
+ this.unifiedLogBuffer.startStreaming();
626
+
627
+ this.lanes = [];
628
+ this.laneProcessStatuses.clear();
629
+ this.view = View.LIST;
630
+ this.showNotification(`Switched to flow: ${flow.runId}`, 'info');
631
+ this.refresh();
632
+ }
633
+ break;
634
+ case 'd':
635
+ // Delete flow (with confirmation)
636
+ if (this.allFlows[this.selectedFlowIndex]) {
637
+ const flow = this.allFlows[this.selectedFlowIndex]!;
638
+ if (flow.isAlive) {
639
+ this.showNotification('Cannot delete a running flow. Stop it first.', 'error');
640
+ } else if (flow.runDir === this.runDir) {
641
+ this.showNotification('Cannot delete the currently viewed flow.', 'error');
642
+ } else {
643
+ this.confirmAction = {
644
+ type: 'delete-flow',
645
+ target: flow.runId,
646
+ time: Date.now(),
647
+ };
648
+ this.render();
649
+ }
650
+ }
651
+ break;
652
+ case 'r':
653
+ // Refresh flows
654
+ this.discoverFlows();
655
+ this.showNotification('Flows refreshed', 'info');
656
+ this.render();
657
+ break;
658
+ case 'escape':
659
+ case 'backspace':
660
+ case 'm':
661
+ this.view = View.LIST;
662
+ this.render();
663
+ break;
664
+ case 'q':
665
+ this.stop();
666
+ break;
667
+ }
668
+ }
669
+
670
+ /**
671
+ * Execute a confirmed action (delete flow, kill process, etc.)
672
+ */
673
+ private executeConfirmedAction(): void {
674
+ if (!this.confirmAction) return;
675
+
676
+ const { type, target } = this.confirmAction;
677
+ this.confirmAction = null;
678
+
679
+ if (type === 'delete-flow') {
680
+ const flow = this.allFlows.find(f => f.runId === target);
681
+ if (flow) {
682
+ try {
683
+ // Delete the flow directory
684
+ fs.rmSync(flow.runDir, { recursive: true, force: true });
685
+ this.showNotification(`Deleted flow: ${target}`, 'success');
686
+
687
+ // Refresh the list
688
+ this.discoverFlows();
689
+
690
+ // Adjust selection if needed
691
+ if (this.selectedFlowIndex >= this.allFlows.length) {
692
+ this.selectedFlowIndex = Math.max(0, this.allFlows.length - 1);
693
+ }
694
+ } catch (err) {
695
+ this.showNotification(`Failed to delete flow: ${err}`, 'error');
696
+ }
697
+ }
698
+ }
699
+
700
+ this.render();
701
+ }
702
+
703
+ private sendIntervention(message: string) {
704
+ if (!this.selectedLaneName) return;
705
+ const lane = this.lanes.find(l => l.name === this.selectedLaneName);
706
+ if (!lane) return;
707
+
708
+ try {
709
+ const interventionPath = safeJoin(lane.path, 'intervention.txt');
710
+ fs.writeFileSync(interventionPath, message, 'utf8');
711
+
712
+ // Also log it to the conversation
713
+ const convoPath = safeJoin(lane.path, 'conversation.jsonl');
714
+ const entry = {
715
+ timestamp: new Date().toISOString(),
716
+ role: 'intervention',
717
+ task: 'INTERVENTION',
718
+ fullText: `[HUMAN INTERVENTION]: ${message}`,
719
+ textLength: message.length + 20,
720
+ model: 'manual'
721
+ };
722
+ fs.appendFileSync(convoPath, JSON.stringify(entry) + '\n', 'utf8');
723
+
724
+ this.showNotification('Intervention message sent', 'success');
725
+ } catch (e) {
726
+ this.showNotification('Failed to send intervention', 'error');
727
+ }
728
+ }
729
+
730
+ private sendTimeoutUpdate(timeoutStr: string) {
731
+ if (!this.selectedLaneName) return;
732
+ const lane = this.lanes.find(l => l.name === this.selectedLaneName);
733
+ if (!lane) return;
734
+
735
+ try {
736
+ const timeoutMs = parseInt(timeoutStr);
737
+ if (isNaN(timeoutMs) || timeoutMs <= 0) {
738
+ this.showNotification('Invalid timeout value', 'error');
739
+ return;
740
+ }
741
+
742
+ const timeoutPath = safeJoin(lane.path, 'timeout.txt');
743
+ fs.writeFileSync(timeoutPath, String(timeoutMs), 'utf8');
744
+
745
+ this.showNotification(`Timeout updated to ${Math.round(timeoutMs/1000)}s`, 'success');
746
+ } catch (e) {
747
+ this.showNotification('Failed to update timeout', 'error');
748
+ }
749
+ }
750
+
751
+ private refreshLogs() {
752
+ if (!this.selectedLaneName) return;
753
+ const lane = this.lanes.find(l => l.name === this.selectedLaneName);
754
+ if (!lane) return;
755
+ const convoPath = safeJoin(lane.path, 'conversation.jsonl');
756
+ this.currentLogs = readLog<ConversationEntry>(convoPath);
757
+ // Keep selection in bounds after refresh
758
+ if (this.selectedMessageIndex >= this.currentLogs.length) {
759
+ this.selectedMessageIndex = Math.max(0, this.currentLogs.length - 1);
760
+ }
761
+ }
762
+
763
+ private refresh() {
764
+ this.lanes = this.listLanesWithDeps(this.runDir);
765
+
766
+ // Update process statuses for accurate display
767
+ this.updateProcessStatuses();
768
+
769
+ if (this.view !== View.LIST && this.view !== View.UNIFIED_LOG && this.view !== View.FLOWS_DASHBOARD) {
770
+ this.refreshLogs();
771
+ }
772
+
773
+ // Refresh flows list periodically
774
+ if (this.view === View.FLOWS_DASHBOARD) {
775
+ this.discoverFlows();
776
+ }
777
+
778
+ this.render();
779
+ }
780
+
781
+ /**
782
+ * Update process statuses for all lanes
783
+ */
784
+ private updateProcessStatuses(): void {
785
+ const lanesDir = safeJoin(this.runDir, 'lanes');
786
+ if (!fs.existsSync(lanesDir)) return;
787
+
788
+ for (const lane of this.lanes) {
789
+ const status = getLaneProcessStatus(lane.path, lane.name);
790
+ this.laneProcessStatuses.set(lane.name, status);
791
+ }
792
+ }
793
+
794
+ private killLane() {
795
+ if (!this.selectedLaneName) return;
796
+ const lane = this.lanes.find(l => l.name === this.selectedLaneName);
797
+ if (!lane) return;
798
+
799
+ const status = this.getLaneStatus(lane.path, lane.name);
800
+ if (status.pid && status.status === 'running') {
801
+ try {
802
+ process.kill(status.pid, 'SIGTERM');
803
+ this.showNotification(`Sent SIGTERM to PID ${status.pid}`, 'success');
804
+ } catch (e) {
805
+ this.showNotification(`Failed to kill PID ${status.pid}`, 'error');
806
+ }
807
+ } else {
808
+ this.showNotification(`No running process found for ${this.selectedLaneName}`, 'info');
809
+ }
810
+ }
811
+
812
+ private showNotification(message: string, type: 'info' | 'error' | 'success') {
813
+ this.notification = { message, type, time: Date.now() };
814
+ this.render();
815
+ }
816
+
817
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
818
+ // UI Layout Helpers - Consistent header/footer across all views
819
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
820
+
821
+ private renderHeader(title: string, breadcrumb: string[] = []): void {
822
+ const width = Math.min(this.screenWidth, 120);
823
+ const line = UI.CHARS.hLine.repeat(width);
824
+
825
+ // Flow status
826
+ const flowSummary = getFlowSummary(this.runDir);
827
+ const flowStatusIcon = flowSummary.isAlive ? '🟢' : (flowSummary.completed === flowSummary.total && flowSummary.total > 0 ? '✅' : '🔴');
828
+
829
+ // Breadcrumb
830
+ const crumbs = ['CursorFlow', ...breadcrumb].join(` ${UI.COLORS.gray}›${UI.COLORS.reset} `);
831
+
832
+ // Time
833
+ const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
834
+
835
+ process.stdout.write(`${UI.COLORS.cyan}${line}${UI.COLORS.reset}\n`);
836
+ process.stdout.write(`${UI.COLORS.bold}${crumbs}${UI.COLORS.reset} ${flowStatusIcon} `);
837
+ process.stdout.write(`${UI.COLORS.dim}${timeStr}${UI.COLORS.reset}\n`);
838
+ process.stdout.write(`${UI.COLORS.cyan}${line}${UI.COLORS.reset}\n`);
839
+ }
840
+
841
+ private renderFooter(actions: string[]): void {
842
+ const width = Math.min(this.screenWidth, 120);
843
+ const line = UI.CHARS.hLine.repeat(width);
844
+
845
+ // Notification area
846
+ if (this.notification && Date.now() - this.notification.time < 3000) {
847
+ const nColor = this.notification.type === 'error' ? UI.COLORS.red
848
+ : this.notification.type === 'success' ? UI.COLORS.green
849
+ : UI.COLORS.cyan;
850
+ process.stdout.write(`\n${nColor}🔔 ${this.notification.message}${UI.COLORS.reset}\n`);
851
+ }
852
+
853
+ // Confirmation dialog area
854
+ if (this.confirmAction && Date.now() - this.confirmAction.time < 10000) {
855
+ const actionName = this.confirmAction.type === 'delete-flow' ? 'DELETE FLOW' : 'KILL PROCESS';
856
+ process.stdout.write(`\n${UI.COLORS.yellow}⚠️ Confirm ${actionName}: ${this.confirmAction.target}? [Y] Yes / [N] No${UI.COLORS.reset}\n`);
857
+ }
858
+
859
+ process.stdout.write(`\n${UI.COLORS.cyan}${line}${UI.COLORS.reset}\n`);
860
+ const formattedActions = actions.map(a => {
861
+ const parts = a.split('] ');
862
+ if (parts.length === 2) {
863
+ // Use regex with global flag to replace all occurrences
864
+ return `${UI.COLORS.yellow}[${parts[0]!.replace(/\[/g, '')}]${UI.COLORS.reset} ${parts[1]}`;
865
+ }
866
+ return a;
867
+ });
868
+ process.stdout.write(` ${formattedActions.join(' ')}\n`);
869
+ }
870
+
871
+ private renderSectionTitle(title: string, extra?: string): void {
872
+ const extraStr = extra ? ` ${UI.COLORS.dim}${extra}${UI.COLORS.reset}` : '';
873
+ process.stdout.write(`\n${UI.COLORS.bold}${title}${UI.COLORS.reset}${extraStr}\n`);
874
+ process.stdout.write(`${UI.COLORS.gray}${''.repeat(40)}${UI.COLORS.reset}\n`);
875
+ }
876
+
877
+ private render() {
878
+ // Clear screen
879
+ process.stdout.write('\x1Bc');
880
+
881
+ // Clear old notifications
882
+ if (this.notification && Date.now() - this.notification.time > 3000) {
883
+ this.notification = null;
884
+ }
885
+
886
+ // Clear old confirmation
887
+ if (this.confirmAction && Date.now() - this.confirmAction.time > 10000) {
888
+ this.confirmAction = null;
889
+ }
890
+
891
+ switch (this.view) {
892
+ case View.LIST:
893
+ this.renderList();
894
+ break;
895
+ case View.LANE_DETAIL:
896
+ this.renderLaneDetail();
897
+ break;
898
+ case View.MESSAGE_DETAIL:
899
+ this.renderMessageDetail();
900
+ break;
901
+ case View.FLOW:
902
+ this.renderFlow();
903
+ break;
904
+ case View.TERMINAL:
905
+ this.renderTerminal();
906
+ break;
907
+ case View.INTERVENE:
908
+ this.renderIntervene();
909
+ break;
910
+ case View.TIMEOUT:
911
+ this.renderTimeout();
912
+ break;
913
+ case View.UNIFIED_LOG:
914
+ this.renderUnifiedLog();
915
+ break;
916
+ case View.FLOWS_DASHBOARD:
917
+ this.renderFlowsDashboard();
918
+ break;
919
+ }
920
+ }
921
+
922
+ private renderList() {
923
+ const flowSummary = getFlowSummary(this.runDir);
924
+ const runId = path.basename(this.runDir);
925
+
926
+ this.renderHeader('Lane Dashboard', [runId]);
927
+
928
+ // Summary line
929
+ const summaryParts = [
930
+ `${flowSummary.running} ${UI.COLORS.cyan}running${UI.COLORS.reset}`,
931
+ `${flowSummary.completed} ${UI.COLORS.green}done${UI.COLORS.reset}`,
932
+ `${flowSummary.failed} ${UI.COLORS.red}failed${UI.COLORS.reset}`,
933
+ `${flowSummary.dead} ${UI.COLORS.yellow}stale${UI.COLORS.reset}`,
934
+ ];
935
+ process.stdout.write(` ${UI.COLORS.dim}Lanes:${UI.COLORS.reset} ${summaryParts.join(' │ ')}\n`);
936
+
937
+ if (this.lanes.length === 0) {
938
+ process.stdout.write(`\n ${UI.COLORS.dim}No lanes found${UI.COLORS.reset}\n`);
939
+ this.renderFooter(['[Q] Quit', '[M] All Flows']);
940
+ return;
941
+ }
942
+
943
+ const laneStatuses: Record<string, any> = {};
944
+ this.lanes.forEach(l => laneStatuses[l.name] = this.getLaneStatus(l.path, l.name));
945
+
946
+ const maxNameLen = Math.max(...this.lanes.map(l => l.name.length), 12);
947
+
948
+ process.stdout.write(`\n ${'Lane'.padEnd(maxNameLen)} ${'Status'.padEnd(12)} ${'PID'.padEnd(7)} ${'Time'.padEnd(8)} ${'Tasks'.padEnd(6)} Next\n`);
949
+ process.stdout.write(` ${'─'.repeat(maxNameLen)} ${'─'.repeat(12)} ${'─'.repeat(7)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(25)}\n`);
950
+
951
+ this.lanes.forEach((lane, i) => {
952
+ const isSelected = i === this.selectedLaneIndex;
953
+ const status = laneStatuses[lane.name];
954
+ const processStatus = this.laneProcessStatuses.get(lane.name);
955
+
956
+ // Determine the accurate status based on process detection
957
+ let displayStatus = status.status;
958
+ let statusColor = UI.COLORS.gray;
959
+ let statusIcon = this.getStatusIcon(status.status);
960
+
961
+ if (processStatus) {
962
+ if (processStatus.isStale) {
963
+ displayStatus = 'STALE';
964
+ statusIcon = '💀';
965
+ statusColor = UI.COLORS.yellow;
966
+ } else if (processStatus.actualStatus === 'dead' && status.status === 'running') {
967
+ displayStatus = 'DEAD';
968
+ statusIcon = '☠️';
969
+ statusColor = UI.COLORS.red;
970
+ } else if (processStatus.actualStatus === 'running') {
971
+ statusColor = UI.COLORS.cyan;
972
+ } else if (status.status === 'completed') {
973
+ statusColor = UI.COLORS.green;
974
+ } else if (status.status === 'failed') {
975
+ statusColor = UI.COLORS.red;
976
+ }
977
+ }
978
+
979
+ const statusText = `${statusIcon} ${displayStatus}`.padEnd(12);
980
+
981
+ // Process indicator
982
+ let pidText = '-'.padEnd(7);
983
+ if (processStatus?.pid) {
984
+ const pidIcon = processStatus.processRunning ? '●' : '○';
985
+ const pidColor = processStatus.processRunning ? UI.COLORS.green : UI.COLORS.red;
986
+ pidText = `${pidColor}${pidIcon}${UI.COLORS.reset}${processStatus.pid}`.padEnd(7 + 9); // +9 for color codes
987
+ }
988
+
989
+ // Duration
990
+ const duration = processStatus?.duration || status.duration;
991
+ const timeText = this.formatDuration(duration).padEnd(8);
992
+
993
+ // Tasks
994
+ let tasksText = '-'.padEnd(6);
995
+ if (typeof status.totalTasks === 'number') {
996
+ tasksText = `${status.currentTask}/${status.totalTasks}`.padEnd(6);
997
+ }
998
+
999
+ // Next action
1000
+ let nextAction = '-';
1001
+ if (status.status === 'completed') {
1002
+ const dependents = this.lanes.filter(l => laneStatuses[l.name]?.dependsOn?.includes(lane.name));
1003
+ nextAction = dependents.length > 0 ? `→ ${dependents.map(d => d.name).join(', ')}` : '✓ Done';
1004
+ } else if (status.status === 'waiting') {
1005
+ if (status.waitingFor?.length > 0) {
1006
+ nextAction = `⏳ ${status.waitingFor.join(', ')}`;
1007
+ } else {
1008
+ const missingDeps = status.dependsOn.filter((d: string) => laneStatuses[d]?.status !== 'completed');
1009
+ nextAction = missingDeps.length > 0 ? `⏳ ${missingDeps.join(', ')}` : '⏳ waiting';
1010
+ }
1011
+ } else if (processStatus?.actualStatus === 'running') {
1012
+ nextAction = '🚀 working...';
1013
+ } else if (processStatus?.isStale) {
1014
+ nextAction = '⚠️ died unexpectedly';
1015
+ }
1016
+
1017
+ // Truncate next action
1018
+ if (nextAction.length > 25) nextAction = nextAction.substring(0, 22) + '...';
1019
+
1020
+ const prefix = isSelected ? ` ${UI.COLORS.cyan}▶${UI.COLORS.reset} ` : ' ';
1021
+ const rowBg = isSelected ? UI.COLORS.bgGray : '';
1022
+ const rowEnd = isSelected ? UI.COLORS.reset : '';
1023
+
1024
+ process.stdout.write(`${rowBg}${prefix}${lane.name.padEnd(maxNameLen)} ${statusColor}${statusText}${UI.COLORS.reset} ${pidText} ${timeText} ${tasksText} ${nextAction}${rowEnd}\n`);
1025
+ });
1026
+
1027
+ this.renderFooter([
1028
+ '[↑↓] Select', '[→/Enter] Details', '[F] Flow', '[U] Unified Logs', '[M] All Flows', '[Q] Quit'
1029
+ ]);
1030
+ }
1031
+
1032
+ private renderLaneDetail() {
1033
+ const lane = this.lanes.find(l => l.name === this.selectedLaneName);
1034
+ if (!lane) {
1035
+ this.view = View.LIST;
1036
+ this.render();
1037
+ return;
1038
+ }
1039
+
1040
+ const status = this.getLaneStatus(lane.path, lane.name);
1041
+ const processStatus = this.laneProcessStatuses.get(lane.name);
1042
+
1043
+ this.renderHeader('Lane Detail', [path.basename(this.runDir), lane.name]);
1044
+
1045
+ // Status grid
1046
+ const statusColor = status.status === 'completed' ? UI.COLORS.green
1047
+ : status.status === 'failed' ? UI.COLORS.red
1048
+ : status.status === 'running' ? UI.COLORS.cyan : UI.COLORS.gray;
1049
+
1050
+ const actualStatus = processStatus?.actualStatus || status.status;
1051
+ const isStale = processStatus?.isStale || false;
1052
+
1053
+ process.stdout.write(`\n`);
1054
+ process.stdout.write(` ${UI.COLORS.dim}Status${UI.COLORS.reset} ${statusColor}${this.getStatusIcon(actualStatus)} ${actualStatus.toUpperCase()}${UI.COLORS.reset}`);
1055
+ if (isStale) process.stdout.write(` ${UI.COLORS.yellow}(stale)${UI.COLORS.reset}`);
1056
+ process.stdout.write(`\n`);
1057
+
1058
+ const pidDisplay = processStatus?.pid
1059
+ ? `${processStatus.processRunning ? UI.COLORS.green : UI.COLORS.red}${processStatus.pid}${UI.COLORS.reset}`
1060
+ : '-';
1061
+ process.stdout.write(` ${UI.COLORS.dim}PID${UI.COLORS.reset} ${pidDisplay}\n`);
1062
+ process.stdout.write(` ${UI.COLORS.dim}Progress${UI.COLORS.reset} ${status.currentTask}/${status.totalTasks} tasks (${status.progress})\n`);
1063
+ process.stdout.write(` ${UI.COLORS.dim}Duration${UI.COLORS.reset} ${this.formatDuration(processStatus?.duration || status.duration)}\n`);
1064
+ process.stdout.write(` ${UI.COLORS.dim}Branch${UI.COLORS.reset} ${status.pipelineBranch}\n`);
1065
+
1066
+ if (status.dependsOn && status.dependsOn.length > 0) {
1067
+ process.stdout.write(` ${UI.COLORS.dim}Depends${UI.COLORS.reset} ${status.dependsOn.join(', ')}\n`);
1068
+ }
1069
+ if (status.waitingFor && status.waitingFor.length > 0) {
1070
+ process.stdout.write(` ${UI.COLORS.yellow}Waiting${UI.COLORS.reset} ${status.waitingFor.join(', ')}\n`);
1071
+ }
1072
+ if (status.error) {
1073
+ process.stdout.write(` ${UI.COLORS.red}Error${UI.COLORS.reset} ${status.error}\n`);
1074
+ }
1075
+
1076
+ // Live terminal preview
1077
+ this.renderSectionTitle('Live Terminal', 'last 10 lines');
1078
+ const logPath = safeJoin(lane.path, 'terminal.log');
1079
+ if (fs.existsSync(logPath)) {
1080
+ const content = fs.readFileSync(logPath, 'utf8');
1081
+ const lines = content.split('\n').slice(-10);
1082
+ for (const line of lines) {
1083
+ const formatted = this.formatTerminalLine(line);
1084
+ process.stdout.write(` ${UI.COLORS.dim}${formatted.substring(0, this.screenWidth - 4)}${UI.COLORS.reset}\n`);
1085
+ }
1086
+ } else {
1087
+ process.stdout.write(` ${UI.COLORS.dim}(No output yet)${UI.COLORS.reset}\n`);
1088
+ }
1089
+
1090
+ // Conversation preview
1091
+ this.renderSectionTitle('Conversation', `${this.currentLogs.length} messages`);
1092
+
1093
+ const maxVisible = 8;
1094
+ if (this.selectedMessageIndex < this.scrollOffset) {
1095
+ this.scrollOffset = this.selectedMessageIndex;
1096
+ } else if (this.selectedMessageIndex >= this.scrollOffset + maxVisible) {
1097
+ this.scrollOffset = this.selectedMessageIndex - maxVisible + 1;
1098
+ }
1099
+
1100
+ if (this.currentLogs.length === 0) {
1101
+ process.stdout.write(` ${UI.COLORS.dim}(No messages yet)${UI.COLORS.reset}\n`);
1102
+ } else {
1103
+ const visibleLogs = this.currentLogs.slice(this.scrollOffset, this.scrollOffset + maxVisible);
1104
+
1105
+ visibleLogs.forEach((log, i) => {
1106
+ const actualIndex = i + this.scrollOffset;
1107
+ const isSelected = actualIndex === this.selectedMessageIndex;
1108
+
1109
+ const roleColor = this.getRoleColor(log.role);
1110
+ const role = log.role.toUpperCase().padEnd(10);
1111
+ const ts = new Date(log.timestamp).toLocaleTimeString('en-US', { hour12: false });
1112
+
1113
+ const prefix = isSelected ? `${UI.COLORS.cyan}▶${UI.COLORS.reset}` : ' ';
1114
+ const bg = isSelected ? UI.COLORS.bgGray : '';
1115
+ const reset = isSelected ? UI.COLORS.reset : '';
1116
+
1117
+ const preview = log.fullText.replace(/\n/g, ' ').substring(0, 60);
1118
+ process.stdout.write(`${bg}${prefix} ${roleColor}${role}${UI.COLORS.reset} ${UI.COLORS.dim}${ts}${UI.COLORS.reset} ${preview}...${reset}\n`);
1119
+ });
1120
+
1121
+ if (this.currentLogs.length > maxVisible) {
1122
+ process.stdout.write(` ${UI.COLORS.dim}(${this.currentLogs.length - maxVisible} more messages)${UI.COLORS.reset}\n`);
1123
+ }
1124
+ }
1125
+
1126
+ this.renderFooter([
1127
+ '[↑↓] Scroll', '[→/Enter] Full Msg', '[T] Terminal', '[I] Intervene', '[K] Kill', '[←/Esc] Back'
1128
+ ]);
1129
+ }
1130
+
1131
+ private getRoleColor(role: string): string {
1132
+ const colors: Record<string, string> = {
1133
+ user: UI.COLORS.yellow,
1134
+ assistant: UI.COLORS.green,
1135
+ reviewer: UI.COLORS.magenta,
1136
+ intervention: UI.COLORS.red,
1137
+ system: UI.COLORS.cyan,
1138
+ };
1139
+ return colors[role] || UI.COLORS.gray;
1140
+ }
1141
+
1142
+ private renderMessageDetail() {
1143
+ const log = this.currentLogs[this.selectedMessageIndex];
1144
+ if (!log) {
1145
+ this.view = View.LANE_DETAIL;
1146
+ this.render();
1147
+ return;
1148
+ }
1149
+
1150
+ this.renderHeader('Message Detail', [path.basename(this.runDir), this.selectedLaneName || '', log.role.toUpperCase()]);
1151
+
1152
+ const roleColor = this.getRoleColor(log.role);
1153
+ const ts = new Date(log.timestamp).toLocaleString();
1154
+
1155
+ process.stdout.write(`\n`);
1156
+ process.stdout.write(` ${UI.COLORS.dim}Role${UI.COLORS.reset} ${roleColor}${log.role.toUpperCase()}${UI.COLORS.reset}\n`);
1157
+ process.stdout.write(` ${UI.COLORS.dim}Time${UI.COLORS.reset} ${ts}\n`);
1158
+ if (log.model) process.stdout.write(` ${UI.COLORS.dim}Model${UI.COLORS.reset} ${log.model}\n`);
1159
+ if (log.task) process.stdout.write(` ${UI.COLORS.dim}Task${UI.COLORS.reset} ${log.task}\n`);
1160
+
1161
+ this.renderSectionTitle('Content');
1162
+
1163
+ // Display message content with wrapping
1164
+ const maxWidth = this.screenWidth - 4;
1165
+ const lines = log.fullText.split('\n');
1166
+ const maxLines = this.screenHeight - 16;
1167
+
1168
+ let lineCount = 0;
1169
+ for (const line of lines) {
1170
+ if (lineCount >= maxLines) {
1171
+ process.stdout.write(` ${UI.COLORS.dim}... (truncated, ${lines.length - lineCount} more lines)${UI.COLORS.reset}\n`);
1172
+ break;
1173
+ }
1174
+
1175
+ // Word wrap long lines
1176
+ if (line.length > maxWidth) {
1177
+ const wrapped = this.wrapText(line, maxWidth);
1178
+ for (const wl of wrapped) {
1179
+ if (lineCount >= maxLines) break;
1180
+ process.stdout.write(` ${wl}\n`);
1181
+ lineCount++;
1182
+ }
1183
+ } else {
1184
+ process.stdout.write(` ${line}\n`);
1185
+ lineCount++;
1186
+ }
1187
+ }
1188
+
1189
+ this.renderFooter(['[←/Esc] Back']);
1190
+ }
1191
+
1192
+ /**
1193
+ * Wrap text to specified width
1194
+ */
1195
+ private wrapText(text: string, maxWidth: number): string[] {
1196
+ const words = text.split(' ');
1197
+ const lines: string[] = [];
1198
+ let currentLine = '';
1199
+
1200
+ for (const word of words) {
1201
+ if (currentLine.length + word.length + 1 <= maxWidth) {
1202
+ currentLine += (currentLine ? ' ' : '') + word;
1203
+ } else {
1204
+ if (currentLine) lines.push(currentLine);
1205
+ currentLine = word;
1206
+ }
1207
+ }
1208
+ if (currentLine) lines.push(currentLine);
1209
+
1210
+ return lines;
1211
+ }
1212
+
1213
+ private renderFlow() {
1214
+ this.renderHeader('Dependency Flow', [path.basename(this.runDir), 'Flow']);
1215
+
1216
+ const laneMap = new Map<string, any>();
1217
+ this.lanes.forEach(lane => {
1218
+ laneMap.set(lane.name, this.getLaneStatus(lane.path, lane.name));
1219
+ });
1220
+
1221
+ process.stdout.write('\n');
1222
+
1223
+ // Group lanes by dependency level
1224
+ const levels = this.calculateDependencyLevels();
1225
+ const maxLevelWidth = Math.max(...levels.map(l => l.length));
1226
+
1227
+ for (let level = 0; level < levels.length; level++) {
1228
+ const lanesAtLevel = levels[level]!;
1229
+
1230
+ // Level header
1231
+ process.stdout.write(` ${UI.COLORS.dim}Level ${level}${UI.COLORS.reset}\n`);
1232
+
1233
+ for (const laneName of lanesAtLevel) {
1234
+ const status = laneMap.get(laneName);
1235
+ const statusIcon = this.getStatusIcon(status?.status || 'pending');
1236
+
1237
+ let statusColor = UI.COLORS.gray;
1238
+ if (status?.status === 'completed') statusColor = UI.COLORS.green;
1239
+ else if (status?.status === 'running') statusColor = UI.COLORS.cyan;
1240
+ else if (status?.status === 'failed') statusColor = UI.COLORS.red;
1241
+
1242
+ // Render the node
1243
+ const nodeText = `${statusIcon} ${laneName}`;
1244
+ process.stdout.write(` ${statusColor}${nodeText.padEnd(20)}${UI.COLORS.reset}`);
1245
+
1246
+ // Render dependencies
1247
+ if (status?.dependsOn?.length > 0) {
1248
+ process.stdout.write(` ${UI.COLORS.dim}←${UI.COLORS.reset} ${UI.COLORS.yellow}${status.dependsOn.join(', ')}${UI.COLORS.reset}`);
1249
+ }
1250
+ process.stdout.write('\n');
1251
+ }
1252
+
1253
+ if (level < levels.length - 1) {
1254
+ process.stdout.write(` ${UI.COLORS.dim}│${UI.COLORS.reset}\n`);
1255
+ process.stdout.write(` ${UI.COLORS.dim}▼${UI.COLORS.reset}\n`);
1256
+ }
1257
+ }
1258
+
1259
+ process.stdout.write(`\n ${UI.COLORS.dim}Lanes wait for dependencies to complete before starting${UI.COLORS.reset}\n`);
1260
+
1261
+ this.renderFooter(['[←/Esc] Back']);
1262
+ }
1263
+
1264
+ /**
1265
+ * Calculate dependency levels for visualization
1266
+ */
1267
+ private calculateDependencyLevels(): string[][] {
1268
+ const levels: string[][] = [];
1269
+ const assigned = new Set<string>();
1270
+
1271
+ // First, find lanes with no dependencies
1272
+ const noDeps = this.lanes.filter(l => !l.dependsOn || l.dependsOn.length === 0);
1273
+ if (noDeps.length > 0) {
1274
+ levels.push(noDeps.map(l => l.name));
1275
+ noDeps.forEach(l => assigned.add(l.name));
1276
+ }
1277
+
1278
+ // Then assign remaining lanes by dependency completion
1279
+ let maxIterations = 10;
1280
+ while (assigned.size < this.lanes.length && maxIterations-- > 0) {
1281
+ const nextLevel: string[] = [];
1282
+
1283
+ for (const lane of this.lanes) {
1284
+ if (assigned.has(lane.name)) continue;
1285
+
1286
+ // Check if all dependencies are assigned
1287
+ const allDepsAssigned = lane.dependsOn.every(d => assigned.has(d));
1288
+ if (allDepsAssigned) {
1289
+ nextLevel.push(lane.name);
1290
+ }
1291
+ }
1292
+
1293
+ if (nextLevel.length === 0) {
1294
+ // Remaining lanes have circular deps or missing deps
1295
+ const remaining = this.lanes.filter(l => !assigned.has(l.name)).map(l => l.name);
1296
+ if (remaining.length > 0) {
1297
+ levels.push(remaining);
1298
+ }
1299
+ break;
1300
+ }
1301
+
1302
+ levels.push(nextLevel);
1303
+ nextLevel.forEach(n => assigned.add(n));
1304
+ }
1305
+
1306
+ return levels;
1307
+ }
1308
+
1309
+ private renderTerminal() {
1310
+ const lane = this.lanes.find(l => l.name === this.selectedLaneName);
1311
+ if (!lane) {
1312
+ this.view = View.LIST;
1313
+ this.render();
1314
+ return;
1315
+ }
1316
+
1317
+ this.renderHeader('Live Terminal', [path.basename(this.runDir), lane.name, 'Terminal']);
1318
+
1319
+ // Get logs based on format mode
1320
+ let logLines: string[] = [];
1321
+ let totalLines = 0;
1322
+
1323
+ if (this.readableFormat) {
1324
+ // Use JSONL for readable format
1325
+ const jsonlPath = safeJoin(lane.path, 'terminal.jsonl');
1326
+ logLines = this.getReadableLogLines(jsonlPath, lane.name);
1327
+ totalLines = logLines.length;
1328
+ } else {
1329
+ // Use raw log
1330
+ const logPath = safeJoin(lane.path, 'terminal.log');
1331
+ if (fs.existsSync(logPath)) {
1332
+ const content = fs.readFileSync(logPath, 'utf8');
1333
+ logLines = content.split('\n');
1334
+ totalLines = logLines.length;
1335
+ }
1336
+ }
1337
+
1338
+ const maxVisible = this.screenHeight - 10;
1339
+
1340
+ // Follow mode logic
1341
+ if (this.followMode) {
1342
+ this.terminalScrollOffset = 0;
1343
+ } else {
1344
+ if (this.lastTerminalTotalLines > 0 && totalLines > this.lastTerminalTotalLines) {
1345
+ this.unseenLineCount += (totalLines - this.lastTerminalTotalLines);
1346
+ this.terminalScrollOffset += (totalLines - this.lastTerminalTotalLines);
1347
+ }
1348
+ }
1349
+ this.lastTerminalTotalLines = totalLines;
1350
+
1351
+ // Clamp scroll offset
1352
+ const maxScroll = Math.max(0, totalLines - maxVisible);
1353
+ if (this.terminalScrollOffset > maxScroll) {
1354
+ this.terminalScrollOffset = maxScroll;
1355
+ }
1356
+
1357
+ // Mode and status indicators
1358
+ const formatMode = this.readableFormat
1359
+ ? `${UI.COLORS.green}[R] Readable ✓${UI.COLORS.reset}`
1360
+ : `${UI.COLORS.dim}[R] Raw${UI.COLORS.reset}`;
1361
+ const followStatus = this.followMode
1362
+ ? `${UI.COLORS.green}[F] Follow ✓${UI.COLORS.reset}`
1363
+ : `${UI.COLORS.yellow}[F] Follow OFF${this.unseenLineCount > 0 ? ` (↓${this.unseenLineCount})` : ''}${UI.COLORS.reset}`;
1364
+
1365
+ process.stdout.write(` ${formatMode} ${followStatus} ${UI.COLORS.dim}Lines: ${totalLines}${UI.COLORS.reset}\n\n`);
1366
+
1367
+ // Slice based on scroll (0 means bottom, >0 means scrolled up)
1368
+ const end = totalLines - this.terminalScrollOffset;
1369
+ const start = Math.max(0, end - maxVisible);
1370
+ const visibleLines = logLines.slice(start, end);
1371
+
1372
+ for (const line of visibleLines) {
1373
+ const formatted = this.readableFormat ? line : this.formatTerminalLine(line);
1374
+ // Truncate to screen width
1375
+ const displayLine = formatted.length > this.screenWidth - 2
1376
+ ? formatted.substring(0, this.screenWidth - 5) + '...'
1377
+ : formatted;
1378
+ process.stdout.write(` ${displayLine}\n`);
1379
+ }
1380
+
1381
+ if (visibleLines.length === 0) {
1382
+ process.stdout.write(` ${UI.COLORS.dim}(No output yet)${UI.COLORS.reset}\n`);
1383
+ }
1384
+
1385
+ this.renderFooter([
1386
+ '[↑↓] Scroll', '[F] Follow', '[R] Toggle Readable', '[I] Intervene', '[←/Esc] Back'
1387
+ ]);
1388
+ }
1389
+
1390
+ /**
1391
+ * Format a raw terminal line with syntax highlighting
1392
+ */
1393
+ private formatTerminalLine(line: string): string {
1394
+ // Highlight patterns
1395
+ if (line.includes('[HUMAN INTERVENTION]') || line.includes('Injecting intervention:')) {
1396
+ return `${UI.COLORS.yellow}${UI.COLORS.bold}${line}${UI.COLORS.reset}`;
1397
+ }
1398
+ if (line.includes('Executing cursor-agent')) {
1399
+ return `${UI.COLORS.cyan}${UI.COLORS.bold}${line}${UI.COLORS.reset}`;
1400
+ }
1401
+ if (line.includes('=== Task:') || line.includes('Starting task:')) {
1402
+ return `${UI.COLORS.green}${UI.COLORS.bold}${line}${UI.COLORS.reset}`;
1403
+ }
1404
+ if (line.toLowerCase().includes('error') || line.toLowerCase().includes('failed')) {
1405
+ return `${UI.COLORS.red}${line}${UI.COLORS.reset}`;
1406
+ }
1407
+ if (line.toLowerCase().includes('success') || line.toLowerCase().includes('completed')) {
1408
+ return `${UI.COLORS.green}${line}${UI.COLORS.reset}`;
1409
+ }
1410
+ return line;
1411
+ }
1412
+
1413
+ /**
1414
+ * Get readable log lines from JSONL file
1415
+ */
1416
+ private getReadableLogLines(jsonlPath: string, laneName: string): string[] {
1417
+ if (!fs.existsSync(jsonlPath)) {
1418
+ // Fallback: try to read raw log
1419
+ const rawPath = jsonlPath.replace('.jsonl', '.log');
1420
+ if (fs.existsSync(rawPath)) {
1421
+ return fs.readFileSync(rawPath, 'utf8').split('\n').map(l => this.formatTerminalLine(l));
1422
+ }
1423
+ return [];
1424
+ }
1425
+
1426
+ try {
1427
+ const content = fs.readFileSync(jsonlPath, 'utf8');
1428
+ const lines = content.split('\n').filter(l => l.trim());
1429
+
1430
+ return lines.map(line => {
1431
+ try {
1432
+ const entry = JSON.parse(line);
1433
+ const ts = new Date(entry.timestamp || Date.now()).toLocaleTimeString('en-US', { hour12: false });
1434
+ const type = (entry.type || 'info').toLowerCase();
1435
+ const content = entry.content || entry.message || '';
1436
+
1437
+ // Format based on type
1438
+ const typeInfo = this.getLogTypeInfo(type);
1439
+ const preview = content.replace(/\n/g, ' ').substring(0, 100);
1440
+
1441
+ return `${UI.COLORS.dim}[${ts}]${UI.COLORS.reset} ${typeInfo.color}[${typeInfo.label}]${UI.COLORS.reset} ${preview}`;
1442
+ } catch {
1443
+ return this.formatTerminalLine(line);
1444
+ }
1445
+ });
1446
+ } catch {
1447
+ return [];
1448
+ }
1449
+ }
1450
+
1451
+ /**
1452
+ * Get log type display info
1453
+ */
1454
+ private getLogTypeInfo(type: string): { label: string; color: string } {
1455
+ const typeMap: Record<string, { label: string; color: string }> = {
1456
+ user: { label: 'USER ', color: UI.COLORS.cyan },
1457
+ assistant: { label: 'ASST ', color: UI.COLORS.green },
1458
+ tool: { label: 'TOOL ', color: UI.COLORS.yellow },
1459
+ tool_result: { label: 'RESULT', color: UI.COLORS.gray },
1460
+ result: { label: 'DONE ', color: UI.COLORS.green },
1461
+ system: { label: 'SYSTEM', color: UI.COLORS.gray },
1462
+ thinking: { label: 'THINK ', color: UI.COLORS.dim },
1463
+ error: { label: 'ERROR ', color: UI.COLORS.red },
1464
+ stderr: { label: 'STDERR', color: UI.COLORS.red },
1465
+ stdout: { label: 'STDOUT', color: UI.COLORS.white },
1466
+ };
1467
+ return typeMap[type] || { label: type.toUpperCase().padEnd(6).substring(0, 6), color: UI.COLORS.gray };
1468
+ }
1469
+
1470
+ private renderIntervene() {
1471
+ this.renderHeader('Human Intervention', [path.basename(this.runDir), this.selectedLaneName || '', 'Intervene']);
1472
+
1473
+ process.stdout.write(`\n`);
1474
+ process.stdout.write(` ${UI.COLORS.yellow}Send a message directly to the agent.${UI.COLORS.reset}\n`);
1475
+ process.stdout.write(` ${UI.COLORS.dim}This will interrupt the current flow and inject your instruction.${UI.COLORS.reset}\n\n`);
1476
+
1477
+ // Input box
1478
+ const width = Math.min(this.screenWidth - 8, 80);
1479
+ process.stdout.write(` ${UI.COLORS.cyan}┌${'─'.repeat(width)}┐${UI.COLORS.reset}\n`);
1480
+
1481
+ // Wrap input text
1482
+ const inputLines = this.wrapText(this.interventionInput || ' ', width - 4);
1483
+ for (const line of inputLines) {
1484
+ process.stdout.write(` ${UI.COLORS.cyan}│${UI.COLORS.reset} ${line.padEnd(width - 2)} ${UI.COLORS.cyan}│${UI.COLORS.reset}\n`);
1485
+ }
1486
+ if (inputLines.length === 0 || inputLines[inputLines.length - 1] === ' ') {
1487
+ process.stdout.write(` ${UI.COLORS.cyan}│${UI.COLORS.reset} ${UI.COLORS.white}█${UI.COLORS.reset}${' '.repeat(width - 3)} ${UI.COLORS.cyan}│${UI.COLORS.reset}\n`);
1488
+ }
1489
+
1490
+ process.stdout.write(` ${UI.COLORS.cyan}└${'─'.repeat(width)}┘${UI.COLORS.reset}\n`);
1491
+
1492
+ this.renderFooter(['[Enter] Send', '[Esc] Cancel']);
1493
+ }
1494
+
1495
+ private renderTimeout() {
1496
+ this.renderHeader('Update Timeout', [path.basename(this.runDir), this.selectedLaneName || '', 'Timeout']);
1497
+
1498
+ process.stdout.write(`\n`);
1499
+ process.stdout.write(` ${UI.COLORS.yellow}Update the task timeout for this lane.${UI.COLORS.reset}\n`);
1500
+ process.stdout.write(` ${UI.COLORS.dim}Enter timeout in milliseconds (e.g., 600000 = 10 minutes)${UI.COLORS.reset}\n\n`);
1501
+
1502
+ // Common presets
1503
+ process.stdout.write(` ${UI.COLORS.dim}Presets: 300000 (5m) | 600000 (10m) | 1800000 (30m) | 3600000 (1h)${UI.COLORS.reset}\n\n`);
1504
+
1505
+ // Input box
1506
+ const width = 40;
1507
+ process.stdout.write(` ${UI.COLORS.cyan}┌${'─'.repeat(width)}┐${UI.COLORS.reset}\n`);
1508
+ process.stdout.write(` ${UI.COLORS.cyan}│${UI.COLORS.reset} ${(this.timeoutInput || '').padEnd(width - 2)}${UI.COLORS.white}█${UI.COLORS.reset} ${UI.COLORS.cyan}│${UI.COLORS.reset}\n`);
1509
+ process.stdout.write(` ${UI.COLORS.cyan}└${'─'.repeat(width)}┘${UI.COLORS.reset}\n`);
1510
+
1511
+ // Show human-readable interpretation
1512
+ if (this.timeoutInput) {
1513
+ const ms = parseInt(this.timeoutInput);
1514
+ if (!isNaN(ms) && ms > 0) {
1515
+ const formatted = this.formatDuration(ms);
1516
+ process.stdout.write(`\n ${UI.COLORS.green}= ${formatted}${UI.COLORS.reset}\n`);
1517
+ }
1518
+ }
1519
+
1520
+ this.renderFooter(['[Enter] Apply', '[Esc] Cancel']);
1521
+ }
1522
+
1523
+ /**
1524
+ * Render unified log view - all lanes combined
1525
+ */
1526
+ private renderUnifiedLog() {
1527
+ this.renderHeader('Unified Logs', [path.basename(this.runDir), 'All Lanes']);
1528
+
1529
+ const bufferState = this.unifiedLogBuffer?.getState();
1530
+ const totalEntries = bufferState?.totalEntries || 0;
1531
+ const availableLanes = bufferState?.lanes || [];
1532
+
1533
+ // Status bar
1534
+ const formatMode = this.readableFormat
1535
+ ? `${UI.COLORS.green}[R] Readable ✓${UI.COLORS.reset}`
1536
+ : `${UI.COLORS.dim}[R] Compact${UI.COLORS.reset}`;
1537
+ const followStatus = this.unifiedLogFollowMode
1538
+ ? `${UI.COLORS.green}[F] Follow ✓${UI.COLORS.reset}`
1539
+ : `${UI.COLORS.yellow}[F] Follow OFF${UI.COLORS.reset}`;
1540
+ const filterStatus = this.laneFilter
1541
+ ? `${UI.COLORS.cyan}[L] ${this.laneFilter}${UI.COLORS.reset}`
1542
+ : `${UI.COLORS.dim}[L] All Lanes${UI.COLORS.reset}`;
1543
+
1544
+ process.stdout.write(` ${formatMode} ${followStatus} ${filterStatus} ${UI.COLORS.dim}Total: ${totalEntries}${UI.COLORS.reset}\n`);
1545
+
1546
+ // Lane list for filtering hint
1547
+ if (availableLanes.length > 1) {
1548
+ process.stdout.write(` ${UI.COLORS.dim}Lanes: ${availableLanes.join(', ')}${UI.COLORS.reset}\n`);
1549
+ }
1550
+ process.stdout.write('\n');
1551
+
1552
+ if (!this.unifiedLogBuffer) {
1553
+ process.stdout.write(` ${UI.COLORS.dim}(No log buffer available)${UI.COLORS.reset}\n`);
1554
+ this.renderFooter(['[U/Esc] Back', '[Q] Quit']);
1555
+ return;
1556
+ }
1557
+
1558
+ const pageSize = this.screenHeight - 12;
1559
+ const filter = this.laneFilter ? { lane: this.laneFilter } : undefined;
1560
+
1561
+ const entries = this.unifiedLogBuffer.getEntries({
1562
+ offset: this.unifiedLogScrollOffset,
1563
+ limit: pageSize,
1564
+ filter,
1565
+ fromEnd: true,
1566
+ });
1567
+
1568
+ if (entries.length === 0) {
1569
+ process.stdout.write(` ${UI.COLORS.dim}(No log entries yet)${UI.COLORS.reset}\n`);
1570
+ } else {
1571
+ for (const entry of entries) {
1572
+ const formatted = this.formatUnifiedLogEntry(entry);
1573
+ const displayLine = formatted.length > this.screenWidth - 2
1574
+ ? formatted.substring(0, this.screenWidth - 5) + '...'
1575
+ : formatted;
1576
+ process.stdout.write(` ${displayLine}\n`);
1577
+ }
1578
+ }
1579
+
1580
+ this.renderFooter([
1581
+ '[↑↓/PgUp/PgDn] Scroll', '[F] Follow', '[R] Readable', '[L] Filter Lane', '[U/Esc] Back'
1582
+ ]);
1583
+ }
1584
+
1585
+ /**
1586
+ * Format a unified log entry
1587
+ */
1588
+ private formatUnifiedLogEntry(entry: BufferedLogEntry): string {
1589
+ const ts = entry.timestamp.toLocaleTimeString('en-US', { hour12: false });
1590
+ const lane = entry.laneName.padEnd(12);
1591
+ const typeInfo = this.getLogTypeInfo(entry.type || 'info');
1592
+
1593
+ if (this.readableFormat) {
1594
+ // Readable format: show more context
1595
+ const content = entry.message.replace(/\n/g, ' ');
1596
+ return `${UI.COLORS.dim}[${ts}]${UI.COLORS.reset} ${entry.laneColor}[${lane}]${UI.COLORS.reset} ${typeInfo.color}[${typeInfo.label}]${UI.COLORS.reset} ${content}`;
1597
+ } else {
1598
+ // Compact format
1599
+ const preview = entry.message.replace(/\n/g, ' ').substring(0, 60);
1600
+ return `${UI.COLORS.dim}${ts}${UI.COLORS.reset} ${entry.laneColor}${entry.laneName.substring(0, 8).padEnd(8)}${UI.COLORS.reset} ${typeInfo.color}${typeInfo.label.trim().substring(0, 4)}${UI.COLORS.reset} ${preview}`;
1601
+ }
1602
+ }
1603
+
1604
+ /**
1605
+ * Render multiple flows dashboard
1606
+ */
1607
+ private renderFlowsDashboard() {
1608
+ this.renderHeader('All Flows', ['Flows Dashboard']);
1609
+
1610
+ process.stdout.write(` ${UI.COLORS.dim}Total: ${this.allFlows.length} flows${UI.COLORS.reset}\n\n`);
1611
+
1612
+ if (this.allFlows.length === 0) {
1613
+ process.stdout.write(` ${UI.COLORS.dim}No flow runs found.${UI.COLORS.reset}\n\n`);
1614
+ process.stdout.write(` Run ${UI.COLORS.cyan}cursorflow run${UI.COLORS.reset} to start a new flow.\n`);
1615
+ this.renderFooter(['[M/Esc] Back', '[Q] Quit']);
1616
+ return;
1617
+ }
1618
+
1619
+ // Header
1620
+ process.stdout.write(` ${'Status'.padEnd(8)} ${'Run ID'.padEnd(32)} ${'Lanes'.padEnd(12)} Progress\n`);
1621
+ process.stdout.write(` ${'─'.repeat(8)} ${'─'.repeat(32)} ${'─'.repeat(12)} ${'─'.repeat(20)}\n`);
1622
+
1623
+ const maxVisible = this.screenHeight - 14;
1624
+ const startIdx = Math.max(0, this.selectedFlowIndex - Math.floor(maxVisible / 2));
1625
+ const endIdx = Math.min(this.allFlows.length, startIdx + maxVisible);
1626
+
1627
+ for (let i = startIdx; i < endIdx; i++) {
1628
+ const flow = this.allFlows[i]!;
1629
+ const isSelected = i === this.selectedFlowIndex;
1630
+ const isCurrent = flow.runDir === this.runDir;
1631
+
1632
+ // Status icon based on flow state
1633
+ let statusIcon = '⚪';
1634
+ if (flow.isAlive) {
1635
+ statusIcon = '🟢';
1636
+ } else if (flow.summary.completed === flow.summary.total && flow.summary.total > 0) {
1637
+ statusIcon = '✅';
1638
+ } else if (flow.summary.failed > 0 || flow.summary.dead > 0) {
1639
+ statusIcon = '🔴';
1640
+ }
1641
+
1642
+ // Lanes summary
1643
+ const lanesSummary = [
1644
+ flow.summary.running > 0 ? `${UI.COLORS.cyan}${flow.summary.running}R${UI.COLORS.reset}` : '',
1645
+ flow.summary.completed > 0 ? `${UI.COLORS.green}${flow.summary.completed}C${UI.COLORS.reset}` : '',
1646
+ flow.summary.failed > 0 ? `${UI.COLORS.red}${flow.summary.failed}F${UI.COLORS.reset}` : '',
1647
+ flow.summary.dead > 0 ? `${UI.COLORS.yellow}${flow.summary.dead}D${UI.COLORS.reset}` : '',
1648
+ ].filter(Boolean).join('/') || '0';
1649
+
1650
+ // Progress bar
1651
+ const total = flow.summary.total || 1;
1652
+ const completed = flow.summary.completed;
1653
+ const ratio = completed / total;
1654
+ const barWidth = 12;
1655
+ const filled = Math.round(ratio * barWidth);
1656
+ const progressBar = `${UI.COLORS.green}${'█'.repeat(filled)}${UI.COLORS.reset}${UI.COLORS.gray}${'░'.repeat(barWidth - filled)}${UI.COLORS.reset}`;
1657
+ const pct = `${Math.round(ratio * 100)}%`;
1658
+
1659
+ // Display
1660
+ const prefix = isSelected ? ` ${UI.COLORS.cyan}▶${UI.COLORS.reset} ` : ' ';
1661
+ const currentTag = isCurrent ? ` ${UI.COLORS.cyan}●${UI.COLORS.reset}` : '';
1662
+ const bg = isSelected ? UI.COLORS.bgGray : '';
1663
+ const resetBg = isSelected ? UI.COLORS.reset : '';
1664
+
1665
+ // Truncate run ID if needed
1666
+ const runIdDisplay = flow.runId.length > 30 ? flow.runId.substring(0, 27) + '...' : flow.runId.padEnd(30);
1667
+
1668
+ process.stdout.write(`${bg}${prefix}${statusIcon} ${runIdDisplay} ${lanesSummary.padEnd(12 + 30)} ${progressBar} ${pct}${currentTag}${resetBg}\n`);
1669
+ }
1670
+
1671
+ if (this.allFlows.length > maxVisible) {
1672
+ process.stdout.write(`\n ${UI.COLORS.dim}(${this.allFlows.length - maxVisible} more flows, scroll to see)${UI.COLORS.reset}\n`);
1673
+ }
1674
+
1675
+ this.renderFooter([
1676
+ '[↑↓] Select', '[→/Enter] Switch', '[D] Delete', '[R] Refresh', '[M/Esc] Back', '[Q] Quit'
1677
+ ]);
1678
+ }
1679
+
1680
+ private listLanesWithDeps(runDir: string): LaneWithDeps[] {
1681
+ const lanesDir = safeJoin(runDir, 'lanes');
1682
+ if (!fs.existsSync(lanesDir)) return [];
1683
+
1684
+ const config = loadConfig();
1685
+ const tasksDir = safeJoin(config.projectRoot, config.tasksDir);
1686
+
1687
+ const laneConfigs = this.listLaneFilesFromDir(tasksDir);
1688
+
1689
+ return fs.readdirSync(lanesDir)
1690
+ .filter(d => fs.statSync(safeJoin(lanesDir, d)).isDirectory())
1691
+ .map(name => {
1692
+ const config = laneConfigs.find(c => c.name === name);
1693
+ return {
1694
+ name,
1695
+ path: safeJoin(lanesDir, name),
1696
+ dependsOn: config?.dependsOn || [],
1697
+ };
1698
+ });
1699
+ }
1700
+
1701
+ private listLaneFilesFromDir(tasksDir: string): { name: string; dependsOn: string[] }[] {
1702
+ if (!fs.existsSync(tasksDir)) return [];
1703
+ return fs.readdirSync(tasksDir)
1704
+ .filter(f => f.endsWith('.json'))
1705
+ .map(f => {
1706
+ const filePath = safeJoin(tasksDir, f);
1707
+ try {
1708
+ const config = JSON.parse(fs.readFileSync(filePath, 'utf8'));
1709
+ return { name: path.basename(f, '.json'), dependsOn: config.dependsOn || [] };
1710
+ } catch {
1711
+ return { name: path.basename(f, '.json'), dependsOn: [] };
1712
+ }
1713
+ });
1714
+ }
1715
+
1716
+ private getLaneStatus(lanePath: string, laneName: string) {
1717
+ const statePath = safeJoin(lanePath, 'state.json');
1718
+ const state = loadState<LaneState & { chatId?: string }>(statePath);
1719
+
1720
+ const laneInfo = this.lanes.find(l => l.name === laneName);
1721
+ const dependsOn = state?.dependsOn || laneInfo?.dependsOn || [];
1722
+
1723
+ if (!state) {
1724
+ return { status: 'pending', currentTask: 0, totalTasks: '?', progress: '0%', dependsOn, duration: 0, pipelineBranch: '-', chatId: '-' };
1725
+ }
1726
+
1727
+ const progress = state.totalTasks > 0 ? Math.round((state.currentTaskIndex / state.totalTasks) * 100) : 0;
1728
+
1729
+ const duration = state.startTime ? (state.endTime
1730
+ ? state.endTime - state.startTime
1731
+ : (state.status === 'running' || state.status === 'reviewing' ? Date.now() - state.startTime : 0)) : 0;
1732
+
1733
+ return {
1734
+ status: state.status || 'unknown',
1735
+ currentTask: state.currentTaskIndex || 0,
1736
+ totalTasks: state.totalTasks || '?',
1737
+ progress: `${progress}%`,
1738
+ pipelineBranch: state.pipelineBranch || '-',
1739
+ chatId: state.chatId || '-',
1740
+ dependsOn,
1741
+ duration,
1742
+ error: state.error,
1743
+ pid: state.pid,
1744
+ waitingFor: state.waitingFor || [],
1745
+ };
1746
+ }
1747
+
1748
+ private formatDuration(ms: number): string {
1749
+ if (ms <= 0) return '-';
1750
+ const seconds = Math.floor((ms / 1000) % 60);
1751
+ const minutes = Math.floor((ms / (1000 * 60)) % 60);
1752
+ const hours = Math.floor(ms / (1000 * 60 * 60));
1753
+
1754
+ if (hours > 0) return `${hours}h ${minutes}m`;
1755
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
1756
+ return `${seconds}s`;
1757
+ }
1758
+
1759
+ private getStatusIcon(status: string): string {
1760
+ const icons: Record<string, string> = {
1761
+ 'running': '🔄',
1762
+ 'waiting': '⏳',
1763
+ 'completed': '✅',
1764
+ 'failed': '❌',
1765
+ 'blocked_dependency': '🚫',
1766
+ 'pending': '⚪',
1767
+ 'reviewing': '👀',
1768
+ };
1769
+ return icons[status] || '❓';
1770
+ }
1771
+ }
1772
+
1773
+ /**
1774
+ * Find the latest run directory
1775
+ */
1776
+ function findLatestRunDir(logsDir: string): string | null {
1777
+ const runsDir = safeJoin(logsDir, 'runs');
1778
+ if (!fs.existsSync(runsDir)) return null;
1779
+ const runs = fs.readdirSync(runsDir)
1780
+ .filter(d => d.startsWith('run-'))
1781
+ .map(d => ({ name: d, path: safeJoin(runsDir, d), mtime: fs.statSync(safeJoin(runsDir, d)).mtime.getTime() }))
1782
+ .sort((a, b) => b.mtime - a.mtime);
1783
+ return runs.length > 0 ? runs[0]!.path : null;
1784
+ }
1785
+
1786
+ /**
1787
+ * Monitor lanes
1788
+ */
1789
+ async function monitor(args: string[]): Promise<void> {
1790
+ const help = args.includes('--help') || args.includes('-h');
1791
+ if (help) {
1792
+ printHelp();
1793
+ return;
1794
+ }
1795
+
1796
+ const intervalIdx = args.indexOf('--interval');
1797
+ const interval = intervalIdx >= 0 ? parseInt(args[intervalIdx + 1] || '2') || 2 : 2;
1798
+
1799
+ const runDirArg = args.find(arg => !arg.startsWith('--') && args.indexOf(arg) !== intervalIdx + 1);
1800
+ const config = loadConfig();
1801
+
1802
+ let runDir = runDirArg;
1803
+ if (!runDir || runDir === 'latest') {
1804
+ runDir = findLatestRunDir(config.logsDir) || undefined;
1805
+ if (!runDir) throw new Error('No run directories found');
1806
+ }
1807
+
1808
+ if (!fs.existsSync(runDir)) throw new Error(`Run directory not found: ${runDir}`);
1809
+
1810
+ const monitor = new InteractiveMonitor(runDir, interval);
1811
+ await monitor.start();
1812
+ }
1813
+
1814
+ export = monitor;
1815
+