@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
package/src/cli/logs.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  JsonLogEntry
15
15
  } from '../utils/enhanced-logger';
16
16
  import { formatPotentialJsonMessage } from '../utils/log-formatter';
17
+ import { startLogViewer } from '../ui/log-viewer';
17
18
 
18
19
  interface LogsOptions {
19
20
  runDir?: string;
@@ -23,6 +24,7 @@ interface LogsOptions {
23
24
  output?: string;
24
25
  tail?: number;
25
26
  follow: boolean;
27
+ interactive: boolean;
26
28
  filter?: string;
27
29
  level?: string;
28
30
  clean: boolean;
@@ -46,12 +48,14 @@ View and export lane logs.
46
48
 
47
49
  Options:
48
50
  [run-dir] Run directory (default: latest)
51
+ --run <id> Specific run directory
49
52
  --lane <name> Filter to specific lane
50
53
  --all, -a View all lanes merged (sorted by timestamp)
51
54
  --format <fmt> Output format: text, json, markdown, html (default: text)
52
55
  --output <path> Write output to file instead of stdout
53
56
  --tail <n> Show last n lines/entries (default: all)
54
57
  --follow, -f Follow log output in real-time
58
+ --interactive, -i Open interactive log viewer
55
59
  --filter <pattern> Filter entries by regex pattern
56
60
  --level <level> Filter by log level: stdout, stderr, info, error, debug
57
61
  --readable, -r Show readable log (parsed AI output) (default)
@@ -68,23 +72,26 @@ Examples:
68
72
  cursorflow logs --all --format json # Export all lanes as JSON
69
73
  cursorflow logs --all --filter "error" # Filter all lanes for errors
70
74
  cursorflow logs --format json --output out.json # Export as JSON
75
+ cursorflow logs -i # Interactive log viewer
76
+ cursorflow logs -i --run <id> # Specific run in interactive mode
71
77
  `);
72
78
  }
73
79
 
74
80
  function parseArgs(args: string[]): LogsOptions {
75
81
  const laneIdx = args.indexOf('--lane');
82
+ const runIdx = args.indexOf('--run');
76
83
  const formatIdx = args.indexOf('--format');
77
84
  const outputIdx = args.indexOf('--output');
78
85
  const tailIdx = args.indexOf('--tail');
79
86
  const filterIdx = args.indexOf('--filter');
80
87
  const levelIdx = args.indexOf('--level');
81
88
 
82
- // Find run directory (first non-option argument)
83
- const runDir = args.find((arg, i) => {
89
+ // Find run directory (first non-option argument or --run value)
90
+ let runDir = runIdx >= 0 ? args[runIdx + 1] : args.find((arg, i) => {
84
91
  if (arg.startsWith('--') || arg.startsWith('-')) return false;
85
92
  // Skip values for options
86
93
  const prevArg = args[i - 1];
87
- if (prevArg && ['--lane', '--format', '--output', '--tail', '--filter', '--level'].includes(prevArg)) {
94
+ if (prevArg && ['--lane', '--run', '--format', '--output', '--tail', '--filter', '--level'].includes(prevArg)) {
88
95
  return false;
89
96
  }
90
97
  return true;
@@ -102,6 +109,7 @@ function parseArgs(args: string[]): LogsOptions {
102
109
  output: outputIdx >= 0 ? args[outputIdx + 1] : undefined,
103
110
  tail: tailIdx >= 0 ? parseInt(args[tailIdx + 1] || '50') : undefined,
104
111
  follow: args.includes('--follow') || args.includes('-f'),
112
+ interactive: args.includes('--interactive') || args.includes('-i'),
105
113
  filter: filterIdx >= 0 ? args[filterIdx + 1] : undefined,
106
114
  level: levelIdx >= 0 ? args[levelIdx + 1] : undefined,
107
115
  raw,
@@ -217,7 +225,7 @@ function displayJsonLogs(
217
225
  if (options.filter) {
218
226
  const filterLower = options.filter.toLowerCase();
219
227
  entries = entries.filter(e =>
220
- e.message.toLowerCase().includes(filterLower) ||
228
+ (e.message || '').toLowerCase().includes(filterLower) ||
221
229
  (e.task && e.task.toLowerCase().includes(filterLower))
222
230
  );
223
231
  }
@@ -232,10 +240,12 @@ function displayJsonLogs(
232
240
  } else {
233
241
  // Display as formatted text
234
242
  for (const entry of entries) {
235
- const levelColor = getLevelColor(entry.level);
243
+ const level = entry.level || 'info';
244
+ const message = entry.message || '';
245
+ const levelColor = getLevelColor(level);
236
246
  const ts = new Date(entry.timestamp).toLocaleTimeString();
237
- const formattedMsg = formatPotentialJsonMessage(entry.message);
238
- console.log(`${levelColor}[${ts}] [${entry.level.toUpperCase().padEnd(6)}]${logger.COLORS.reset} ${formattedMsg}`);
247
+ const formattedMsg = formatPotentialJsonMessage(message);
248
+ console.log(`${levelColor}[${ts}] [${level.toUpperCase().padEnd(6)}]${logger.COLORS.reset} ${formattedMsg}`);
239
249
  }
240
250
  }
241
251
  }
@@ -343,7 +353,7 @@ function displayMergedLogs(runDir: string, options: LogsOptions): void {
343
353
  if (options.filter) {
344
354
  const filterLower = options.filter.toLowerCase();
345
355
  entries = entries.filter(e =>
346
- e.message.toLowerCase().includes(filterLower) ||
356
+ (e.message || '').toLowerCase().includes(filterLower) ||
347
357
  (e.task && e.task.toLowerCase().includes(filterLower)) ||
348
358
  e.laneName.toLowerCase().includes(filterLower)
349
359
  );
@@ -378,22 +388,25 @@ function displayMergedLogs(runDir: string, options: LogsOptions): void {
378
388
  // Display entries
379
389
  for (const entry of entries) {
380
390
  const ts = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false });
381
- const levelColor = getLevelColor(entry.level);
391
+ const level = entry.level || 'info';
392
+ const levelColor = getLevelColor(level);
382
393
  const laneColor = entry.laneColor;
383
394
  const lanePad = entry.laneName.substring(0, 12).padEnd(12);
384
- const levelPad = entry.level.toUpperCase().padEnd(6);
395
+ const levelPad = level.toUpperCase().padEnd(6);
396
+
397
+ const message = entry.message || '';
385
398
 
386
399
  // Skip session entries for cleaner output unless they're important
387
- if (entry.level === 'session' && entry.message === 'Session started') {
400
+ if (level === 'session' && message === 'Session started') {
388
401
  console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${laneColor}[${lanePad}]${logger.COLORS.reset} ${logger.COLORS.cyan}── Session Started ──${logger.COLORS.reset}`);
389
402
  continue;
390
403
  }
391
- if (entry.level === 'session' && entry.message === 'Session ended') {
404
+ if (level === 'session' && message === 'Session ended') {
392
405
  console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${laneColor}[${lanePad}]${logger.COLORS.reset} ${logger.COLORS.cyan}── Session Ended ──${logger.COLORS.reset}`);
393
406
  continue;
394
407
  }
395
408
 
396
- const formattedMsg = formatPotentialJsonMessage(entry.message);
409
+ const formattedMsg = formatPotentialJsonMessage(message);
397
410
  console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${formattedMsg}`);
398
411
  }
399
412
 
@@ -436,14 +449,14 @@ function followAllLogs(runDir: string, options: LogsOptions): void {
436
449
  const laneDir = safeJoin(runDir, 'lanes', lane);
437
450
  const jsonLogPath = safeJoin(laneDir, 'terminal.jsonl');
438
451
 
452
+ let fd: number | null = null;
439
453
  try {
440
- // Use statSync directly to avoid TOCTOU race condition
441
- const stats = fs.statSync(jsonLogPath);
454
+ // Use fstat on open fd to avoid TOCTOU race condition
455
+ fd = fs.openSync(jsonLogPath, 'r');
456
+ const stats = fs.fstatSync(fd);
442
457
  if (stats.size > lastPositions[lane]!) {
443
- const fd = fs.openSync(jsonLogPath, 'r');
444
458
  const buffer = Buffer.alloc(stats.size - lastPositions[lane]!);
445
459
  fs.readSync(fd, buffer, 0, buffer.length, lastPositions[lane]!);
446
- fs.closeSync(fd);
447
460
 
448
461
  const content = buffer.toString();
449
462
  const lines = content.split('\n').filter(l => l.trim());
@@ -465,6 +478,10 @@ function followAllLogs(runDir: string, options: LogsOptions): void {
465
478
  }
466
479
  } catch {
467
480
  // Ignore errors
481
+ } finally {
482
+ if (fd !== null) {
483
+ try { fs.closeSync(fd); } catch { /* ignore */ }
484
+ }
468
485
  }
469
486
  }
470
487
 
@@ -477,13 +494,16 @@ function followAllLogs(runDir: string, options: LogsOptions): void {
477
494
 
478
495
  // Apply filters and display
479
496
  for (let entry of newEntries) {
497
+ const level = entry.level || 'info';
498
+ const message = entry.message || '';
499
+
480
500
  // Apply level filter
481
- if (options.level && entry.level !== options.level) continue;
501
+ if (options.level && level !== options.level) continue;
482
502
 
483
503
  // Apply filter (case-insensitive string match to avoid ReDoS)
484
504
  if (options.filter) {
485
505
  const filterLower = options.filter.toLowerCase();
486
- if (!entry.message.toLowerCase().includes(filterLower) &&
506
+ if (!message.toLowerCase().includes(filterLower) &&
487
507
  !(entry.task && entry.task.toLowerCase().includes(filterLower)) &&
488
508
  !entry.laneName.toLowerCase().includes(filterLower)) {
489
509
  continue;
@@ -491,21 +511,21 @@ function followAllLogs(runDir: string, options: LogsOptions): void {
491
511
  }
492
512
 
493
513
  const ts = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false });
494
- const levelColor = getLevelColor(entry.level);
514
+ const levelColor = getLevelColor(level);
495
515
  const lanePad = entry.laneName.substring(0, 12).padEnd(12);
496
- const levelPad = entry.level.toUpperCase().padEnd(6);
516
+ const levelPad = level.toUpperCase().padEnd(6);
497
517
 
498
518
  // Skip verbose session entries
499
- if (entry.level === 'session') {
500
- if (entry.message === 'Session started') {
519
+ if (level === 'session') {
520
+ if (message === 'Session started') {
501
521
  console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${entry.laneColor}[${lanePad}]${logger.COLORS.reset} ${logger.COLORS.cyan}── Session Started ──${logger.COLORS.reset}`);
502
- } else if (entry.message === 'Session ended') {
522
+ } else if (message === 'Session ended') {
503
523
  console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${entry.laneColor}[${lanePad}]${logger.COLORS.reset} ${logger.COLORS.cyan}── Session Ended ──${logger.COLORS.reset}`);
504
524
  }
505
525
  continue;
506
526
  }
507
527
 
508
- const formattedMsg = formatPotentialJsonMessage(entry.message);
528
+ const formattedMsg = formatPotentialJsonMessage(message);
509
529
  console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${entry.laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${formattedMsg}`);
510
530
  }
511
531
  }, 100);
@@ -542,7 +562,9 @@ function exportMergedLogs(runDir: string, format: string, outputPath?: string):
542
562
  // Text format
543
563
  for (const entry of entries) {
544
564
  const ts = new Date(entry.timestamp).toISOString();
545
- output += `[${ts}] [${entry.laneName}] [${entry.level.toUpperCase()}] ${entry.message}\n`;
565
+ const level = entry.level || 'info';
566
+ const message = entry.message || '';
567
+ output += `[${ts}] [${entry.laneName}] [${level.toUpperCase()}] ${message}\n`;
546
568
  }
547
569
  }
548
570
 
@@ -570,8 +592,14 @@ function exportMergedToMarkdown(entries: MergedLogEntry[], runDir: string): stri
570
592
 
571
593
  for (const entry of entries) {
572
594
  const ts = new Date(entry.timestamp).toLocaleTimeString();
573
- const message = entry.message.replace(/\|/g, '\\|').substring(0, 80);
574
- md += `| ${ts} | ${entry.laneName} | ${entry.level} | ${message} |\n`;
595
+ const level = entry.level || 'info';
596
+ // Escape markdown table special characters: pipe, backslash, and newlines
597
+ const message = (entry.message || '')
598
+ .replace(/\\/g, '\\\\')
599
+ .replace(/\|/g, '\\|')
600
+ .replace(/\n/g, ' ')
601
+ .substring(0, 80);
602
+ md += `| ${ts} | ${entry.laneName} | ${level} | ${message} |\n`;
575
603
  }
576
604
 
577
605
  return md;
@@ -625,12 +653,14 @@ function exportMergedToHtml(entries: MergedLogEntry[], runDir: string): string {
625
653
  const ts = new Date(entry.timestamp).toLocaleTimeString();
626
654
  const laneIndex = lanes.indexOf(entry.laneName);
627
655
  const color = colors[laneIndex % colors.length];
656
+ const level = entry.level || 'info';
657
+ const message = entry.message || '';
628
658
 
629
- html += ` <div class="entry ${entry.level}">
659
+ html += ` <div class="entry ${level}">
630
660
  <span class="time">${ts}</span>
631
661
  <span class="lane" style="color: ${color}">${entry.laneName}</span>
632
- <span class="level">[${entry.level.toUpperCase()}]</span>
633
- <span class="message">${escapeHtml(entry.message)}</span>
662
+ <span class="level">[${level.toUpperCase()}]</span>
663
+ <span class="message">${escapeHtml(message)}</span>
634
664
  </div>\n`;
635
665
  }
636
666
 
@@ -685,15 +715,14 @@ function followLogs(laneDir: string, options: LogsOptions): void {
685
715
  console.log(`${logger.COLORS.cyan}Following ${logFile}... (Ctrl+C to stop)${logger.COLORS.reset}\n`);
686
716
 
687
717
  const checkInterval = setInterval(() => {
718
+ // Use fstat on open fd to avoid TOCTOU race condition
719
+ let fd: number | null = null;
688
720
  try {
689
- if (!fs.existsSync(logFile)) return;
690
-
691
- const stats = fs.statSync(logFile);
721
+ fd = fs.openSync(logFile, 'r');
722
+ const stats = fs.fstatSync(fd);
692
723
  if (stats.size > lastSize) {
693
- const fd = fs.openSync(logFile, 'r');
694
724
  const buffer = Buffer.alloc(stats.size - lastSize);
695
725
  fs.readSync(fd, buffer, 0, buffer.length, lastSize);
696
- fs.closeSync(fd);
697
726
 
698
727
  let content = buffer.toString();
699
728
 
@@ -715,8 +744,12 @@ function followLogs(laneDir: string, options: LogsOptions): void {
715
744
 
716
745
  lastSize = stats.size;
717
746
  }
718
- } catch (e) {
747
+ } catch {
719
748
  // Ignore errors (file might be rotating)
749
+ } finally {
750
+ if (fd !== null) {
751
+ try { fs.closeSync(fd); } catch { /* ignore */ }
752
+ }
720
753
  }
721
754
  }, 100);
722
755
 
@@ -804,13 +837,17 @@ async function logs(args: string[]): Promise<void> {
804
837
  let runDir = options.runDir;
805
838
  if (!runDir || runDir === 'latest') {
806
839
  runDir = findLatestRunDir(config.logsDir) || undefined;
807
- if (!runDir) {
808
- throw new Error('No run directories found');
809
- }
810
840
  }
811
841
 
812
- if (!fs.existsSync(runDir)) {
813
- throw new Error(`Run directory not found: ${runDir}`);
842
+ if (!runDir || !fs.existsSync(runDir)) {
843
+ console.error('No run found');
844
+ process.exit(1);
845
+ }
846
+
847
+ // Handle interactive mode
848
+ if (options.interactive) {
849
+ await startLogViewer(runDir);
850
+ return;
814
851
  }
815
852
 
816
853
  // Handle --all option (view all lanes merged)