@litmers/cursorflow-orchestrator 0.1.13 → 0.1.14

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 (68) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +83 -2
  3. package/commands/cursorflow-clean.md +20 -6
  4. package/commands/cursorflow-prepare.md +1 -1
  5. package/commands/cursorflow-resume.md +127 -6
  6. package/commands/cursorflow-run.md +2 -2
  7. package/commands/cursorflow-signal.md +11 -4
  8. package/dist/cli/clean.js +164 -12
  9. package/dist/cli/clean.js.map +1 -1
  10. package/dist/cli/index.d.ts +1 -0
  11. package/dist/cli/index.js +6 -1
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/cli/logs.d.ts +8 -0
  14. package/dist/cli/logs.js +746 -0
  15. package/dist/cli/logs.js.map +1 -0
  16. package/dist/cli/monitor.js +113 -30
  17. package/dist/cli/monitor.js.map +1 -1
  18. package/dist/cli/prepare.js +1 -1
  19. package/dist/cli/resume.js +367 -18
  20. package/dist/cli/resume.js.map +1 -1
  21. package/dist/cli/run.js +2 -0
  22. package/dist/cli/run.js.map +1 -1
  23. package/dist/cli/signal.js +34 -20
  24. package/dist/cli/signal.js.map +1 -1
  25. package/dist/core/orchestrator.d.ts +11 -1
  26. package/dist/core/orchestrator.js +257 -35
  27. package/dist/core/orchestrator.js.map +1 -1
  28. package/dist/core/reviewer.js +20 -0
  29. package/dist/core/reviewer.js.map +1 -1
  30. package/dist/core/runner.js +113 -13
  31. package/dist/core/runner.js.map +1 -1
  32. package/dist/utils/config.js +34 -0
  33. package/dist/utils/config.js.map +1 -1
  34. package/dist/utils/enhanced-logger.d.ts +209 -0
  35. package/dist/utils/enhanced-logger.js +963 -0
  36. package/dist/utils/enhanced-logger.js.map +1 -0
  37. package/dist/utils/events.d.ts +59 -0
  38. package/dist/utils/events.js +37 -0
  39. package/dist/utils/events.js.map +1 -0
  40. package/dist/utils/git.d.ts +5 -0
  41. package/dist/utils/git.js +25 -0
  42. package/dist/utils/git.js.map +1 -1
  43. package/dist/utils/types.d.ts +122 -1
  44. package/dist/utils/webhook.d.ts +5 -0
  45. package/dist/utils/webhook.js +109 -0
  46. package/dist/utils/webhook.js.map +1 -0
  47. package/examples/README.md +1 -1
  48. package/package.json +1 -1
  49. package/scripts/simple-logging-test.sh +97 -0
  50. package/scripts/test-real-logging.sh +289 -0
  51. package/scripts/test-streaming-multi-task.sh +247 -0
  52. package/src/cli/clean.ts +170 -13
  53. package/src/cli/index.ts +4 -1
  54. package/src/cli/logs.ts +848 -0
  55. package/src/cli/monitor.ts +123 -30
  56. package/src/cli/prepare.ts +1 -1
  57. package/src/cli/resume.ts +463 -22
  58. package/src/cli/run.ts +2 -0
  59. package/src/cli/signal.ts +43 -27
  60. package/src/core/orchestrator.ts +303 -37
  61. package/src/core/reviewer.ts +22 -0
  62. package/src/core/runner.ts +128 -12
  63. package/src/utils/config.ts +36 -0
  64. package/src/utils/enhanced-logger.ts +1097 -0
  65. package/src/utils/events.ts +117 -0
  66. package/src/utils/git.ts +25 -0
  67. package/src/utils/types.ts +150 -1
  68. package/src/utils/webhook.ts +85 -0
@@ -0,0 +1,848 @@
1
+ /**
2
+ * CursorFlow logs command - View and export logs
3
+ */
4
+
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import * as logger from '../utils/logger';
8
+ import { loadConfig } from '../utils/config';
9
+ import {
10
+ readJsonLog,
11
+ exportLogs,
12
+ stripAnsi,
13
+ JsonLogEntry
14
+ } from '../utils/enhanced-logger';
15
+
16
+ interface LogsOptions {
17
+ runDir?: string;
18
+ lane?: string;
19
+ all: boolean; // View all lanes merged
20
+ format: 'text' | 'json' | 'markdown' | 'html';
21
+ output?: string;
22
+ tail?: number;
23
+ follow: boolean;
24
+ filter?: string;
25
+ level?: string;
26
+ clean: boolean;
27
+ raw: boolean;
28
+ readable: boolean; // Show readable parsed log
29
+ help: boolean;
30
+ }
31
+
32
+ function printHelp(): void {
33
+ console.log(`
34
+ Usage: cursorflow logs [run-dir] [options]
35
+
36
+ View and export lane logs.
37
+
38
+ Options:
39
+ [run-dir] Run directory (default: latest)
40
+ --lane <name> Filter to specific lane
41
+ --all, -a View all lanes merged (sorted by timestamp)
42
+ --format <fmt> Output format: text, json, markdown, html (default: text)
43
+ --output <path> Write output to file instead of stdout
44
+ --tail <n> Show last n lines/entries (default: all)
45
+ --follow, -f Follow log output in real-time
46
+ --filter <pattern> Filter entries by regex pattern
47
+ --level <level> Filter by log level: stdout, stderr, info, error, debug
48
+ --clean Show clean logs without ANSI codes (default)
49
+ --raw Show raw logs with ANSI codes
50
+ --readable, -r Show readable log (parsed streaming output)
51
+ --help, -h Show help
52
+
53
+ Examples:
54
+ cursorflow logs # View latest run logs summary
55
+ cursorflow logs --lane api-setup # View specific lane logs
56
+ cursorflow logs --lane api-setup --readable # View readable parsed log
57
+ cursorflow logs --all # View all lanes merged by time
58
+ cursorflow logs --all --follow # Follow all lanes in real-time
59
+ cursorflow logs --all --format json # Export all lanes as JSON
60
+ cursorflow logs --all --filter "error" # Filter all lanes for errors
61
+ cursorflow logs --format json --output out.json # Export as JSON
62
+ `);
63
+ }
64
+
65
+ function parseArgs(args: string[]): LogsOptions {
66
+ const laneIdx = args.indexOf('--lane');
67
+ const formatIdx = args.indexOf('--format');
68
+ const outputIdx = args.indexOf('--output');
69
+ const tailIdx = args.indexOf('--tail');
70
+ const filterIdx = args.indexOf('--filter');
71
+ const levelIdx = args.indexOf('--level');
72
+
73
+ // Find run directory (first non-option argument)
74
+ const runDir = args.find((arg, i) => {
75
+ if (arg.startsWith('--') || arg.startsWith('-')) return false;
76
+ // Skip values for options
77
+ const prevArg = args[i - 1];
78
+ if (prevArg && ['--lane', '--format', '--output', '--tail', '--filter', '--level'].includes(prevArg)) {
79
+ return false;
80
+ }
81
+ return true;
82
+ });
83
+
84
+ return {
85
+ runDir,
86
+ lane: laneIdx >= 0 ? args[laneIdx + 1] : undefined,
87
+ all: args.includes('--all') || args.includes('-a'),
88
+ format: (formatIdx >= 0 ? args[formatIdx + 1] : 'text') as LogsOptions['format'],
89
+ output: outputIdx >= 0 ? args[outputIdx + 1] : undefined,
90
+ tail: tailIdx >= 0 ? parseInt(args[tailIdx + 1] || '50') : undefined,
91
+ follow: args.includes('--follow') || args.includes('-f'),
92
+ filter: filterIdx >= 0 ? args[filterIdx + 1] : undefined,
93
+ level: levelIdx >= 0 ? args[levelIdx + 1] : undefined,
94
+ clean: !args.includes('--raw') && !args.includes('--readable') && !args.includes('-r'),
95
+ raw: args.includes('--raw'),
96
+ readable: args.includes('--readable') || args.includes('-r'),
97
+ help: args.includes('--help') || args.includes('-h'),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Find the latest run directory
103
+ */
104
+ function findLatestRunDir(logsDir: string): string | null {
105
+ const runsDir = path.join(logsDir, 'runs');
106
+ if (!fs.existsSync(runsDir)) return null;
107
+
108
+ const runs = fs.readdirSync(runsDir)
109
+ .filter(d => d.startsWith('run-'))
110
+ .map(d => ({
111
+ name: d,
112
+ path: path.join(runsDir, d),
113
+ mtime: fs.statSync(path.join(runsDir, d)).mtime.getTime()
114
+ }))
115
+ .sort((a, b) => b.mtime - a.mtime);
116
+
117
+ return runs.length > 0 ? runs[0]!.path : null;
118
+ }
119
+
120
+ /**
121
+ * List lanes in a run directory
122
+ */
123
+ function listLanes(runDir: string): string[] {
124
+ const lanesDir = path.join(runDir, 'lanes');
125
+ if (!fs.existsSync(lanesDir)) return [];
126
+
127
+ return fs.readdirSync(lanesDir)
128
+ .filter(d => fs.statSync(path.join(lanesDir, d)).isDirectory());
129
+ }
130
+
131
+ /**
132
+ * Read and display text logs
133
+ */
134
+ function displayTextLogs(
135
+ laneDir: string,
136
+ options: LogsOptions
137
+ ): void {
138
+ let logFile: string;
139
+ if (options.readable) {
140
+ logFile = path.join(laneDir, 'terminal-readable.log');
141
+ } else if (options.raw) {
142
+ logFile = path.join(laneDir, 'terminal-raw.log');
143
+ } else {
144
+ logFile = path.join(laneDir, 'terminal.log');
145
+ }
146
+
147
+ if (!fs.existsSync(logFile)) {
148
+ if (options.readable) {
149
+ console.log('Readable log not found. This log is only available for runs with streaming output enabled.');
150
+ } else {
151
+ console.log('No log file found.');
152
+ }
153
+ return;
154
+ }
155
+
156
+ let content = fs.readFileSync(logFile, 'utf8');
157
+ let lines = content.split('\n');
158
+
159
+ // Apply filter
160
+ if (options.filter) {
161
+ const regex = new RegExp(options.filter, 'i');
162
+ lines = lines.filter(line => regex.test(line));
163
+ }
164
+
165
+ // Apply tail
166
+ if (options.tail && lines.length > options.tail) {
167
+ lines = lines.slice(-options.tail);
168
+ }
169
+
170
+ // Clean ANSI if needed (for clean mode)
171
+ if (options.clean && !options.raw) {
172
+ lines = lines.map(line => stripAnsi(line));
173
+ }
174
+
175
+ console.log(lines.join('\n'));
176
+ }
177
+
178
+ /**
179
+ * Read and display JSON logs
180
+ */
181
+ function displayJsonLogs(
182
+ laneDir: string,
183
+ options: LogsOptions
184
+ ): void {
185
+ const logFile = path.join(laneDir, 'terminal.jsonl');
186
+
187
+ if (!fs.existsSync(logFile)) {
188
+ console.log('No JSON log file found.');
189
+ return;
190
+ }
191
+
192
+ let entries = readJsonLog(logFile);
193
+
194
+ // Apply level filter
195
+ if (options.level) {
196
+ entries = entries.filter(e => e.level === options.level);
197
+ }
198
+
199
+ // Apply regex filter
200
+ if (options.filter) {
201
+ const regex = new RegExp(options.filter, 'i');
202
+ entries = entries.filter(e => regex.test(e.message) || regex.test(e.task || ''));
203
+ }
204
+
205
+ // Apply tail
206
+ if (options.tail && entries.length > options.tail) {
207
+ entries = entries.slice(-options.tail);
208
+ }
209
+
210
+ if (options.format === 'json') {
211
+ console.log(JSON.stringify(entries, null, 2));
212
+ } else {
213
+ // Display as formatted text
214
+ for (const entry of entries) {
215
+ const levelColor = getLevelColor(entry.level);
216
+ const ts = new Date(entry.timestamp).toLocaleTimeString();
217
+ console.log(`${levelColor}[${ts}] [${entry.level.toUpperCase().padEnd(6)}]${logger.COLORS.reset} ${entry.message}`);
218
+ }
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Get color for log level
224
+ */
225
+ function getLevelColor(level: string): string {
226
+ switch (level) {
227
+ case 'error':
228
+ return logger.COLORS.red;
229
+ case 'stderr':
230
+ return logger.COLORS.yellow;
231
+ case 'info':
232
+ case 'session':
233
+ return logger.COLORS.cyan;
234
+ case 'debug':
235
+ return logger.COLORS.gray;
236
+ default:
237
+ return logger.COLORS.reset;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Lane color palette for distinguishing lanes in merged view
243
+ */
244
+ const LANE_COLORS = [
245
+ '\x1b[38;5;39m', // Blue
246
+ '\x1b[38;5;208m', // Orange
247
+ '\x1b[38;5;156m', // Light Green
248
+ '\x1b[38;5;213m', // Pink
249
+ '\x1b[38;5;87m', // Cyan
250
+ '\x1b[38;5;228m', // Yellow
251
+ '\x1b[38;5;183m', // Light Purple
252
+ '\x1b[38;5;121m', // Sea Green
253
+ ];
254
+
255
+ /**
256
+ * Get consistent color for a lane name
257
+ */
258
+ function getLaneColor(laneName: string, laneIndex: number): string {
259
+ return LANE_COLORS[laneIndex % LANE_COLORS.length]!;
260
+ }
261
+
262
+ /**
263
+ * Extended JSON log entry with lane info
264
+ */
265
+ interface MergedLogEntry extends JsonLogEntry {
266
+ laneName: string;
267
+ laneColor: string;
268
+ }
269
+
270
+ /**
271
+ * Read and merge all lane logs
272
+ */
273
+ function readAllLaneLogs(runDir: string): MergedLogEntry[] {
274
+ const lanes = listLanes(runDir);
275
+ const allEntries: MergedLogEntry[] = [];
276
+
277
+ lanes.forEach((laneName, index) => {
278
+ const laneDir = path.join(runDir, 'lanes', laneName);
279
+ const jsonLogPath = path.join(laneDir, 'terminal.jsonl');
280
+
281
+ if (fs.existsSync(jsonLogPath)) {
282
+ const entries = readJsonLog(jsonLogPath);
283
+ const laneColor = getLaneColor(laneName, index);
284
+
285
+ for (const entry of entries) {
286
+ allEntries.push({
287
+ ...entry,
288
+ laneName,
289
+ laneColor,
290
+ });
291
+ }
292
+ }
293
+ });
294
+
295
+ // Sort by timestamp
296
+ allEntries.sort((a, b) => {
297
+ const timeA = new Date(a.timestamp).getTime();
298
+ const timeB = new Date(b.timestamp).getTime();
299
+ return timeA - timeB;
300
+ });
301
+
302
+ return allEntries;
303
+ }
304
+
305
+ /**
306
+ * Display merged logs from all lanes
307
+ */
308
+ function displayMergedLogs(runDir: string, options: LogsOptions): void {
309
+ let entries = readAllLaneLogs(runDir);
310
+
311
+ if (entries.length === 0) {
312
+ console.log('No log entries found in any lane.');
313
+ return;
314
+ }
315
+
316
+ // Apply level filter
317
+ if (options.level) {
318
+ entries = entries.filter(e => e.level === options.level);
319
+ }
320
+
321
+ // Apply regex filter
322
+ if (options.filter) {
323
+ const regex = new RegExp(options.filter, 'i');
324
+ entries = entries.filter(e =>
325
+ regex.test(e.message) ||
326
+ regex.test(e.task || '') ||
327
+ regex.test(e.laneName)
328
+ );
329
+ }
330
+
331
+ // Apply tail
332
+ if (options.tail && entries.length > options.tail) {
333
+ entries = entries.slice(-options.tail);
334
+ }
335
+
336
+ // Get unique lanes for legend
337
+ const lanes = [...new Set(entries.map(e => e.laneName))];
338
+
339
+ // Print header
340
+ console.log(`\n${logger.COLORS.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${logger.COLORS.reset}`);
341
+ console.log(`${logger.COLORS.cyan} 🔀 Merged Logs - ${path.basename(runDir)} (${entries.length} entries from ${lanes.length} lanes)${logger.COLORS.reset}`);
342
+ console.log(`${logger.COLORS.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${logger.COLORS.reset}`);
343
+
344
+ // Print lane legend
345
+ console.log('\n Lanes: ' + lanes.map((lane, i) => {
346
+ const color = getLaneColor(lane, lanes.indexOf(lane));
347
+ return `${color}■${logger.COLORS.reset} ${lane}`;
348
+ }).join(' '));
349
+ console.log('');
350
+
351
+ // Format output based on format option
352
+ if (options.format === 'json') {
353
+ console.log(JSON.stringify(entries, null, 2));
354
+ return;
355
+ }
356
+
357
+ // Display entries
358
+ for (const entry of entries) {
359
+ const ts = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false });
360
+ const levelColor = getLevelColor(entry.level);
361
+ const laneColor = entry.laneColor;
362
+ const lanePad = entry.laneName.substring(0, 12).padEnd(12);
363
+ const levelPad = entry.level.toUpperCase().padEnd(6);
364
+
365
+ // Skip session entries for cleaner output unless they're important
366
+ if (entry.level === 'session' && entry.message === 'Session started') {
367
+ console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${laneColor}[${lanePad}]${logger.COLORS.reset} ${logger.COLORS.cyan}── Session Started ──${logger.COLORS.reset}`);
368
+ continue;
369
+ }
370
+ if (entry.level === 'session' && entry.message === 'Session ended') {
371
+ console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${laneColor}[${lanePad}]${logger.COLORS.reset} ${logger.COLORS.cyan}── Session Ended ──${logger.COLORS.reset}`);
372
+ continue;
373
+ }
374
+
375
+ console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${entry.message}`);
376
+ }
377
+
378
+ console.log(`\n${logger.COLORS.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${logger.COLORS.reset}`);
379
+ }
380
+
381
+ /**
382
+ * Follow all lanes in real-time
383
+ */
384
+ function followAllLogs(runDir: string, options: LogsOptions): void {
385
+ const lanes = listLanes(runDir);
386
+
387
+ if (lanes.length === 0) {
388
+ console.log('No lanes found.');
389
+ return;
390
+ }
391
+
392
+ // Track last read position for each lane
393
+ const lastPositions: Record<string, number> = {};
394
+ const laneColors: Record<string, string> = {};
395
+
396
+ lanes.forEach((lane, index) => {
397
+ lastPositions[lane] = 0;
398
+ laneColors[lane] = getLaneColor(lane, index);
399
+ });
400
+
401
+ // Print header
402
+ console.log(`\n${logger.COLORS.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${logger.COLORS.reset}`);
403
+ console.log(`${logger.COLORS.cyan} 🔴 Following All Lanes - ${path.basename(runDir)} (Ctrl+C to stop)${logger.COLORS.reset}`);
404
+ console.log(`${logger.COLORS.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${logger.COLORS.reset}`);
405
+ console.log('\n Lanes: ' + lanes.map((lane, i) => {
406
+ return `${laneColors[lane]}■${logger.COLORS.reset} ${lane}`;
407
+ }).join(' '));
408
+ console.log('');
409
+
410
+ const checkInterval = setInterval(() => {
411
+ const newEntries: MergedLogEntry[] = [];
412
+
413
+ for (const lane of lanes) {
414
+ const laneDir = path.join(runDir, 'lanes', lane);
415
+ const jsonLogPath = path.join(laneDir, 'terminal.jsonl');
416
+
417
+ if (!fs.existsSync(jsonLogPath)) continue;
418
+
419
+ try {
420
+ const stats = fs.statSync(jsonLogPath);
421
+ if (stats.size > lastPositions[lane]!) {
422
+ const fd = fs.openSync(jsonLogPath, 'r');
423
+ const buffer = Buffer.alloc(stats.size - lastPositions[lane]!);
424
+ fs.readSync(fd, buffer, 0, buffer.length, lastPositions[lane]!);
425
+ fs.closeSync(fd);
426
+
427
+ const content = buffer.toString();
428
+ const lines = content.split('\n').filter(l => l.trim());
429
+
430
+ for (const line of lines) {
431
+ try {
432
+ const entry = JSON.parse(line) as JsonLogEntry;
433
+ newEntries.push({
434
+ ...entry,
435
+ laneName: lane,
436
+ laneColor: laneColors[lane]!,
437
+ });
438
+ } catch {
439
+ // Skip invalid lines
440
+ }
441
+ }
442
+
443
+ lastPositions[lane] = stats.size;
444
+ }
445
+ } catch {
446
+ // Ignore errors
447
+ }
448
+ }
449
+
450
+ // Sort new entries by timestamp
451
+ newEntries.sort((a, b) => {
452
+ const timeA = new Date(a.timestamp).getTime();
453
+ const timeB = new Date(b.timestamp).getTime();
454
+ return timeA - timeB;
455
+ });
456
+
457
+ // Apply filters and display
458
+ for (let entry of newEntries) {
459
+ // Apply level filter
460
+ if (options.level && entry.level !== options.level) continue;
461
+
462
+ // Apply regex filter
463
+ if (options.filter) {
464
+ const regex = new RegExp(options.filter, 'i');
465
+ if (!regex.test(entry.message) && !regex.test(entry.task || '') && !regex.test(entry.laneName)) {
466
+ continue;
467
+ }
468
+ }
469
+
470
+ const ts = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false });
471
+ const levelColor = getLevelColor(entry.level);
472
+ const lanePad = entry.laneName.substring(0, 12).padEnd(12);
473
+ const levelPad = entry.level.toUpperCase().padEnd(6);
474
+
475
+ // Skip verbose session entries
476
+ if (entry.level === 'session') {
477
+ if (entry.message === 'Session started') {
478
+ console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${entry.laneColor}[${lanePad}]${logger.COLORS.reset} ${logger.COLORS.cyan}── Session Started ──${logger.COLORS.reset}`);
479
+ } else if (entry.message === 'Session ended') {
480
+ console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${entry.laneColor}[${lanePad}]${logger.COLORS.reset} ${logger.COLORS.cyan}── Session Ended ──${logger.COLORS.reset}`);
481
+ }
482
+ continue;
483
+ }
484
+
485
+ console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${entry.laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${entry.message}`);
486
+ }
487
+ }, 100);
488
+
489
+ // Handle Ctrl+C
490
+ process.on('SIGINT', () => {
491
+ clearInterval(checkInterval);
492
+ console.log('\n\nStopped following logs.');
493
+ process.exit(0);
494
+ });
495
+ }
496
+
497
+ /**
498
+ * Export merged logs to various formats
499
+ */
500
+ function exportMergedLogs(runDir: string, format: string, outputPath?: string): string {
501
+ let entries = readAllLaneLogs(runDir);
502
+ let output = '';
503
+
504
+ switch (format) {
505
+ case 'json':
506
+ output = JSON.stringify(entries, null, 2);
507
+ break;
508
+
509
+ case 'markdown':
510
+ output = exportMergedToMarkdown(entries, runDir);
511
+ break;
512
+
513
+ case 'html':
514
+ output = exportMergedToHtml(entries, runDir);
515
+ break;
516
+
517
+ default:
518
+ // Text format
519
+ for (const entry of entries) {
520
+ const ts = new Date(entry.timestamp).toISOString();
521
+ output += `[${ts}] [${entry.laneName}] [${entry.level.toUpperCase()}] ${entry.message}\n`;
522
+ }
523
+ }
524
+
525
+ if (outputPath) {
526
+ fs.writeFileSync(outputPath, output, 'utf8');
527
+ }
528
+
529
+ return output;
530
+ }
531
+
532
+ /**
533
+ * Export merged logs to Markdown
534
+ */
535
+ function exportMergedToMarkdown(entries: MergedLogEntry[], runDir: string): string {
536
+ const lanes = [...new Set(entries.map(e => e.laneName))];
537
+
538
+ let md = `# CursorFlow Merged Logs\n\n`;
539
+ md += `**Run:** ${path.basename(runDir)}\n`;
540
+ md += `**Lanes:** ${lanes.join(', ')}\n`;
541
+ md += `**Entries:** ${entries.length}\n\n`;
542
+
543
+ md += `## Timeline\n\n`;
544
+ md += '| Time | Lane | Level | Message |\n';
545
+ md += '|------|------|-------|--------|\n';
546
+
547
+ for (const entry of entries) {
548
+ const ts = new Date(entry.timestamp).toLocaleTimeString();
549
+ const message = entry.message.replace(/\|/g, '\\|').substring(0, 80);
550
+ md += `| ${ts} | ${entry.laneName} | ${entry.level} | ${message} |\n`;
551
+ }
552
+
553
+ return md;
554
+ }
555
+
556
+ /**
557
+ * Export merged logs to HTML
558
+ */
559
+ function exportMergedToHtml(entries: MergedLogEntry[], runDir: string): string {
560
+ const lanes = [...new Set(entries.map(e => e.laneName))];
561
+
562
+ let html = `<!DOCTYPE html>
563
+ <html>
564
+ <head>
565
+ <title>CursorFlow Merged Logs - ${path.basename(runDir)}</title>
566
+ <style>
567
+ body { font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; margin: 20px; background: #1e1e1e; color: #d4d4d4; }
568
+ h1, h2 { color: #569cd6; }
569
+ .legend { margin: 10px 0; padding: 10px; background: #252526; border-radius: 4px; }
570
+ .legend-item { display: inline-block; margin-right: 15px; }
571
+ .legend-color { display: inline-block; width: 12px; height: 12px; margin-right: 5px; border-radius: 2px; }
572
+ .entry { padding: 4px 8px; margin: 2px 0; border-radius: 4px; display: flex; }
573
+ .entry .time { color: #808080; width: 80px; flex-shrink: 0; }
574
+ .entry .lane { width: 120px; flex-shrink: 0; font-weight: bold; }
575
+ .entry .level { width: 70px; flex-shrink: 0; }
576
+ .entry .message { flex: 1; white-space: pre-wrap; }
577
+ .entry.stdout { background: #252526; }
578
+ .entry.stderr { background: #3c1f1f; }
579
+ .entry.stderr .level { color: #f48771; }
580
+ .entry.info { background: #1e3a5f; }
581
+ .entry.error { background: #5f1e1e; }
582
+ .entry.session { background: #1e4620; color: #6a9955; }
583
+ </style>
584
+ </head>
585
+ <body>
586
+ <h1>🔀 CursorFlow Merged Logs</h1>
587
+ <p><strong>Run:</strong> ${path.basename(runDir)} | <strong>Entries:</strong> ${entries.length}</p>
588
+
589
+ <div class="legend">
590
+ <strong>Lanes:</strong> `;
591
+
592
+ const colors = ['#5dade2', '#f39c12', '#58d68d', '#af7ac5', '#48c9b0', '#f7dc6f', '#bb8fce', '#76d7c4'];
593
+ lanes.forEach((lane, i) => {
594
+ const color = colors[i % colors.length];
595
+ html += `<span class="legend-item"><span class="legend-color" style="background: ${color}"></span>${lane}</span>`;
596
+ });
597
+
598
+ html += `</div>\n`;
599
+
600
+ for (const entry of entries) {
601
+ const ts = new Date(entry.timestamp).toLocaleTimeString();
602
+ const laneIndex = lanes.indexOf(entry.laneName);
603
+ const color = colors[laneIndex % colors.length];
604
+
605
+ html += ` <div class="entry ${entry.level}">
606
+ <span class="time">${ts}</span>
607
+ <span class="lane" style="color: ${color}">${entry.laneName}</span>
608
+ <span class="level">[${entry.level.toUpperCase()}]</span>
609
+ <span class="message">${escapeHtml(entry.message)}</span>
610
+ </div>\n`;
611
+ }
612
+
613
+ html += '</body></html>';
614
+ return html;
615
+ }
616
+
617
+ function escapeHtml(text: string): string {
618
+ return text
619
+ .replace(/&/g, '&amp;')
620
+ .replace(/</g, '&lt;')
621
+ .replace(/>/g, '&gt;')
622
+ .replace(/"/g, '&quot;')
623
+ .replace(/'/g, '&#039;');
624
+ }
625
+
626
+ /**
627
+ * Follow logs in real-time
628
+ */
629
+ function followLogs(laneDir: string, options: LogsOptions): void {
630
+ let logFile: string;
631
+ if (options.readable) {
632
+ logFile = path.join(laneDir, 'terminal-readable.log');
633
+ } else if (options.raw) {
634
+ logFile = path.join(laneDir, 'terminal-raw.log');
635
+ } else {
636
+ logFile = path.join(laneDir, 'terminal.log');
637
+ }
638
+
639
+ if (!fs.existsSync(logFile)) {
640
+ console.log('Waiting for log file...');
641
+ }
642
+
643
+ let lastSize = 0;
644
+ try {
645
+ lastSize = fs.existsSync(logFile) ? fs.statSync(logFile).size : 0;
646
+ } catch {
647
+ // Ignore
648
+ }
649
+
650
+ console.log(`${logger.COLORS.cyan}Following ${logFile}... (Ctrl+C to stop)${logger.COLORS.reset}\n`);
651
+
652
+ const checkInterval = setInterval(() => {
653
+ try {
654
+ if (!fs.existsSync(logFile)) return;
655
+
656
+ const stats = fs.statSync(logFile);
657
+ if (stats.size > lastSize) {
658
+ const fd = fs.openSync(logFile, 'r');
659
+ const buffer = Buffer.alloc(stats.size - lastSize);
660
+ fs.readSync(fd, buffer, 0, buffer.length, lastSize);
661
+ fs.closeSync(fd);
662
+
663
+ let content = buffer.toString();
664
+
665
+ // Apply filter
666
+ if (options.filter) {
667
+ const regex = new RegExp(options.filter, 'i');
668
+ const lines = content.split('\n');
669
+ content = lines.filter(line => regex.test(line)).join('\n');
670
+ }
671
+
672
+ // Clean ANSI if needed
673
+ if (options.clean && !options.raw) {
674
+ content = stripAnsi(content);
675
+ }
676
+
677
+ if (content.trim()) {
678
+ process.stdout.write(content);
679
+ }
680
+
681
+ lastSize = stats.size;
682
+ }
683
+ } catch (e) {
684
+ // Ignore errors (file might be rotating)
685
+ }
686
+ }, 100);
687
+
688
+ // Handle Ctrl+C
689
+ process.on('SIGINT', () => {
690
+ clearInterval(checkInterval);
691
+ console.log('\n\nStopped following logs.');
692
+ process.exit(0);
693
+ });
694
+ }
695
+
696
+ /**
697
+ * Display logs summary for all lanes
698
+ */
699
+ function displaySummary(runDir: string): void {
700
+ const lanes = listLanes(runDir);
701
+
702
+ console.log(`\n${logger.COLORS.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${logger.COLORS.reset}`);
703
+ console.log(`${logger.COLORS.cyan} 📋 Logs Summary - ${path.basename(runDir)}${logger.COLORS.reset}`);
704
+ console.log(`${logger.COLORS.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${logger.COLORS.reset}\n`);
705
+
706
+ if (lanes.length === 0) {
707
+ console.log(' No lanes found.');
708
+ return;
709
+ }
710
+
711
+ for (const lane of lanes) {
712
+ const laneDir = path.join(runDir, 'lanes', lane);
713
+ const cleanLog = path.join(laneDir, 'terminal.log');
714
+ const rawLog = path.join(laneDir, 'terminal-raw.log');
715
+ const jsonLog = path.join(laneDir, 'terminal.jsonl');
716
+ const readableLog = path.join(laneDir, 'terminal-readable.log');
717
+
718
+ console.log(` ${logger.COLORS.green}📁 ${lane}${logger.COLORS.reset}`);
719
+
720
+ if (fs.existsSync(cleanLog)) {
721
+ const stats = fs.statSync(cleanLog);
722
+ console.log(` └─ terminal.log ${formatSize(stats.size)}`);
723
+ }
724
+
725
+ if (fs.existsSync(rawLog)) {
726
+ const stats = fs.statSync(rawLog);
727
+ console.log(` └─ terminal-raw.log ${formatSize(stats.size)}`);
728
+ }
729
+
730
+ if (fs.existsSync(readableLog)) {
731
+ const stats = fs.statSync(readableLog);
732
+ console.log(` └─ terminal-readable.log ${formatSize(stats.size)} ${logger.COLORS.yellow}(parsed AI output)${logger.COLORS.reset}`);
733
+ }
734
+
735
+ if (fs.existsSync(jsonLog)) {
736
+ const stats = fs.statSync(jsonLog);
737
+ const entries = readJsonLog(jsonLog);
738
+ const errors = entries.filter(e => e.level === 'error' || e.level === 'stderr').length;
739
+ console.log(` └─ terminal.jsonl ${formatSize(stats.size)} (${entries.length} entries, ${errors} errors)`);
740
+ }
741
+
742
+ console.log('');
743
+ }
744
+ }
745
+
746
+ /**
747
+ * Format file size
748
+ */
749
+ function formatSize(bytes: number): string {
750
+ if (bytes < 1024) return `${bytes} B`;
751
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
752
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
753
+ }
754
+
755
+ /**
756
+ * Main logs command
757
+ */
758
+ async function logs(args: string[]): Promise<void> {
759
+ const options = parseArgs(args);
760
+
761
+ if (options.help) {
762
+ printHelp();
763
+ return;
764
+ }
765
+
766
+ const config = loadConfig();
767
+
768
+ // Find run directory
769
+ let runDir = options.runDir;
770
+ if (!runDir || runDir === 'latest') {
771
+ runDir = findLatestRunDir(config.logsDir) || undefined;
772
+ if (!runDir) {
773
+ throw new Error('No run directories found');
774
+ }
775
+ }
776
+
777
+ if (!fs.existsSync(runDir)) {
778
+ throw new Error(`Run directory not found: ${runDir}`);
779
+ }
780
+
781
+ // Handle --all option (view all lanes merged)
782
+ if (options.all) {
783
+ // Handle follow mode for all lanes
784
+ if (options.follow) {
785
+ followAllLogs(runDir, options);
786
+ return;
787
+ }
788
+
789
+ // Handle export for all lanes
790
+ if (options.output) {
791
+ exportMergedLogs(runDir, options.format, options.output);
792
+ console.log(`Exported merged logs to: ${options.output}`);
793
+ return;
794
+ }
795
+
796
+ // Display merged logs
797
+ if (options.format === 'json') {
798
+ const entries = readAllLaneLogs(runDir);
799
+ console.log(JSON.stringify(entries, null, 2));
800
+ } else if (options.format === 'markdown' || options.format === 'html') {
801
+ const content = exportMergedLogs(runDir, options.format);
802
+ console.log(content);
803
+ } else {
804
+ displayMergedLogs(runDir, options);
805
+ }
806
+ return;
807
+ }
808
+
809
+ // If no lane specified, show summary
810
+ if (!options.lane) {
811
+ displaySummary(runDir);
812
+ console.log(`${logger.COLORS.gray}Use --lane <name> to view logs, --readable for parsed AI output, or --all to view all lanes merged${logger.COLORS.reset}`);
813
+ return;
814
+ }
815
+
816
+ // Find lane directory
817
+ const laneDir = path.join(runDir, 'lanes', options.lane);
818
+ if (!fs.existsSync(laneDir)) {
819
+ const lanes = listLanes(runDir);
820
+ throw new Error(`Lane not found: ${options.lane}\nAvailable lanes: ${lanes.join(', ')}`);
821
+ }
822
+
823
+ // Handle follow mode
824
+ if (options.follow) {
825
+ followLogs(laneDir, options);
826
+ return;
827
+ }
828
+
829
+ // Handle export
830
+ if (options.output) {
831
+ const content = exportLogs(laneDir, options.format, options.output);
832
+ console.log(`Exported logs to: ${options.output}`);
833
+ return;
834
+ }
835
+
836
+ // Display logs
837
+ if (options.format === 'json' || options.level) {
838
+ displayJsonLogs(laneDir, options);
839
+ } else if (options.format === 'markdown' || options.format === 'html') {
840
+ const content = exportLogs(laneDir, options.format);
841
+ console.log(content);
842
+ } else {
843
+ displayTextLogs(laneDir, options);
844
+ }
845
+ }
846
+
847
+ export = logs;
848
+