@litmers/cursorflow-orchestrator 0.1.18 → 0.1.26

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 (234) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +25 -7
  3. package/commands/cursorflow-clean.md +19 -0
  4. package/commands/cursorflow-runs.md +59 -0
  5. package/commands/cursorflow-stop.md +55 -0
  6. package/dist/cli/clean.js +178 -6
  7. package/dist/cli/clean.js.map +1 -1
  8. package/dist/cli/index.js +12 -1
  9. package/dist/cli/index.js.map +1 -1
  10. package/dist/cli/init.js +8 -7
  11. package/dist/cli/init.js.map +1 -1
  12. package/dist/cli/logs.js +126 -77
  13. package/dist/cli/logs.js.map +1 -1
  14. package/dist/cli/monitor.d.ts +7 -0
  15. package/dist/cli/monitor.js +1021 -202
  16. package/dist/cli/monitor.js.map +1 -1
  17. package/dist/cli/prepare.js +39 -21
  18. package/dist/cli/prepare.js.map +1 -1
  19. package/dist/cli/resume.js +268 -163
  20. package/dist/cli/resume.js.map +1 -1
  21. package/dist/cli/run.js +11 -5
  22. package/dist/cli/run.js.map +1 -1
  23. package/dist/cli/runs.d.ts +5 -0
  24. package/dist/cli/runs.js +214 -0
  25. package/dist/cli/runs.js.map +1 -0
  26. package/dist/cli/setup-commands.js +0 -0
  27. package/dist/cli/signal.js +8 -8
  28. package/dist/cli/signal.js.map +1 -1
  29. package/dist/cli/stop.d.ts +5 -0
  30. package/dist/cli/stop.js +215 -0
  31. package/dist/cli/stop.js.map +1 -0
  32. package/dist/cli/tasks.d.ts +10 -0
  33. package/dist/cli/tasks.js +165 -0
  34. package/dist/cli/tasks.js.map +1 -0
  35. package/dist/core/auto-recovery.d.ts +212 -0
  36. package/dist/core/auto-recovery.js +737 -0
  37. package/dist/core/auto-recovery.js.map +1 -0
  38. package/dist/core/failure-policy.d.ts +156 -0
  39. package/dist/core/failure-policy.js +488 -0
  40. package/dist/core/failure-policy.js.map +1 -0
  41. package/dist/core/orchestrator.d.ts +16 -2
  42. package/dist/core/orchestrator.js +439 -105
  43. package/dist/core/orchestrator.js.map +1 -1
  44. package/dist/core/reviewer.d.ts +2 -0
  45. package/dist/core/reviewer.js +2 -0
  46. package/dist/core/reviewer.js.map +1 -1
  47. package/dist/core/runner.d.ts +33 -10
  48. package/dist/core/runner.js +374 -164
  49. package/dist/core/runner.js.map +1 -1
  50. package/dist/services/logging/buffer.d.ts +67 -0
  51. package/dist/services/logging/buffer.js +309 -0
  52. package/dist/services/logging/buffer.js.map +1 -0
  53. package/dist/services/logging/console.d.ts +89 -0
  54. package/dist/services/logging/console.js +169 -0
  55. package/dist/services/logging/console.js.map +1 -0
  56. package/dist/services/logging/file-writer.d.ts +71 -0
  57. package/dist/services/logging/file-writer.js +516 -0
  58. package/dist/services/logging/file-writer.js.map +1 -0
  59. package/dist/services/logging/formatter.d.ts +39 -0
  60. package/dist/services/logging/formatter.js +227 -0
  61. package/dist/services/logging/formatter.js.map +1 -0
  62. package/dist/services/logging/index.d.ts +11 -0
  63. package/dist/services/logging/index.js +30 -0
  64. package/dist/services/logging/index.js.map +1 -0
  65. package/dist/services/logging/parser.d.ts +31 -0
  66. package/dist/services/logging/parser.js +222 -0
  67. package/dist/services/logging/parser.js.map +1 -0
  68. package/dist/services/process/index.d.ts +59 -0
  69. package/dist/services/process/index.js +257 -0
  70. package/dist/services/process/index.js.map +1 -0
  71. package/dist/types/agent.d.ts +20 -0
  72. package/dist/types/agent.js +6 -0
  73. package/dist/types/agent.js.map +1 -0
  74. package/dist/types/config.d.ts +65 -0
  75. package/dist/types/config.js +6 -0
  76. package/dist/types/config.js.map +1 -0
  77. package/dist/types/events.d.ts +125 -0
  78. package/dist/types/events.js +6 -0
  79. package/dist/types/events.js.map +1 -0
  80. package/dist/types/index.d.ts +12 -0
  81. package/dist/types/index.js +37 -0
  82. package/dist/types/index.js.map +1 -0
  83. package/dist/types/lane.d.ts +43 -0
  84. package/dist/types/lane.js +6 -0
  85. package/dist/types/lane.js.map +1 -0
  86. package/dist/types/logging.d.ts +71 -0
  87. package/dist/types/logging.js +16 -0
  88. package/dist/types/logging.js.map +1 -0
  89. package/dist/types/review.d.ts +17 -0
  90. package/dist/types/review.js +6 -0
  91. package/dist/types/review.js.map +1 -0
  92. package/dist/types/run.d.ts +32 -0
  93. package/dist/types/run.js +6 -0
  94. package/dist/types/run.js.map +1 -0
  95. package/dist/types/task.d.ts +71 -0
  96. package/dist/types/task.js +6 -0
  97. package/dist/types/task.js.map +1 -0
  98. package/dist/ui/components.d.ts +134 -0
  99. package/dist/ui/components.js +389 -0
  100. package/dist/ui/components.js.map +1 -0
  101. package/dist/ui/log-viewer.d.ts +49 -0
  102. package/dist/ui/log-viewer.js +449 -0
  103. package/dist/ui/log-viewer.js.map +1 -0
  104. package/dist/utils/checkpoint.d.ts +87 -0
  105. package/dist/utils/checkpoint.js +317 -0
  106. package/dist/utils/checkpoint.js.map +1 -0
  107. package/dist/utils/config.d.ts +4 -0
  108. package/dist/utils/config.js +18 -8
  109. package/dist/utils/config.js.map +1 -1
  110. package/dist/utils/cursor-agent.js.map +1 -1
  111. package/dist/utils/dependency.d.ts +74 -0
  112. package/dist/utils/dependency.js +420 -0
  113. package/dist/utils/dependency.js.map +1 -0
  114. package/dist/utils/doctor.js +17 -11
  115. package/dist/utils/doctor.js.map +1 -1
  116. package/dist/utils/enhanced-logger.d.ts +10 -33
  117. package/dist/utils/enhanced-logger.js +108 -20
  118. package/dist/utils/enhanced-logger.js.map +1 -1
  119. package/dist/utils/git.d.ts +121 -0
  120. package/dist/utils/git.js +484 -11
  121. package/dist/utils/git.js.map +1 -1
  122. package/dist/utils/health.d.ts +91 -0
  123. package/dist/utils/health.js +556 -0
  124. package/dist/utils/health.js.map +1 -0
  125. package/dist/utils/lock.d.ts +95 -0
  126. package/dist/utils/lock.js +332 -0
  127. package/dist/utils/lock.js.map +1 -0
  128. package/dist/utils/log-buffer.d.ts +17 -0
  129. package/dist/utils/log-buffer.js +14 -0
  130. package/dist/utils/log-buffer.js.map +1 -0
  131. package/dist/utils/log-constants.d.ts +23 -0
  132. package/dist/utils/log-constants.js +28 -0
  133. package/dist/utils/log-constants.js.map +1 -0
  134. package/dist/utils/log-formatter.d.ts +25 -0
  135. package/dist/utils/log-formatter.js +237 -0
  136. package/dist/utils/log-formatter.js.map +1 -0
  137. package/dist/utils/log-service.d.ts +19 -0
  138. package/dist/utils/log-service.js +47 -0
  139. package/dist/utils/log-service.js.map +1 -0
  140. package/dist/utils/logger.d.ts +46 -27
  141. package/dist/utils/logger.js +82 -60
  142. package/dist/utils/logger.js.map +1 -1
  143. package/dist/utils/path.d.ts +19 -0
  144. package/dist/utils/path.js +77 -0
  145. package/dist/utils/path.js.map +1 -0
  146. package/dist/utils/process-manager.d.ts +21 -0
  147. package/dist/utils/process-manager.js +138 -0
  148. package/dist/utils/process-manager.js.map +1 -0
  149. package/dist/utils/retry.d.ts +121 -0
  150. package/dist/utils/retry.js +374 -0
  151. package/dist/utils/retry.js.map +1 -0
  152. package/dist/utils/run-service.d.ts +88 -0
  153. package/dist/utils/run-service.js +412 -0
  154. package/dist/utils/run-service.js.map +1 -0
  155. package/dist/utils/state.d.ts +62 -3
  156. package/dist/utils/state.js +317 -11
  157. package/dist/utils/state.js.map +1 -1
  158. package/dist/utils/task-service.d.ts +82 -0
  159. package/dist/utils/task-service.js +348 -0
  160. package/dist/utils/task-service.js.map +1 -0
  161. package/dist/utils/template.d.ts +14 -0
  162. package/dist/utils/template.js +122 -0
  163. package/dist/utils/template.js.map +1 -0
  164. package/dist/utils/types.d.ts +2 -271
  165. package/dist/utils/types.js +16 -0
  166. package/dist/utils/types.js.map +1 -1
  167. package/package.json +38 -23
  168. package/scripts/ai-security-check.js +0 -1
  169. package/scripts/local-security-gate.sh +0 -0
  170. package/scripts/monitor-lanes.sh +94 -0
  171. package/scripts/patches/test-cursor-agent.js +0 -1
  172. package/scripts/release.sh +0 -0
  173. package/scripts/setup-security.sh +0 -0
  174. package/scripts/stream-logs.sh +72 -0
  175. package/scripts/verify-and-fix.sh +0 -0
  176. package/src/cli/clean.ts +187 -6
  177. package/src/cli/index.ts +12 -1
  178. package/src/cli/init.ts +8 -7
  179. package/src/cli/logs.ts +124 -77
  180. package/src/cli/monitor.ts +1815 -898
  181. package/src/cli/prepare.ts +41 -21
  182. package/src/cli/resume.ts +753 -626
  183. package/src/cli/run.ts +12 -5
  184. package/src/cli/runs.ts +212 -0
  185. package/src/cli/setup-commands.ts +0 -0
  186. package/src/cli/signal.ts +8 -7
  187. package/src/cli/stop.ts +209 -0
  188. package/src/cli/tasks.ts +154 -0
  189. package/src/core/auto-recovery.ts +909 -0
  190. package/src/core/failure-policy.ts +592 -0
  191. package/src/core/orchestrator.ts +1131 -704
  192. package/src/core/reviewer.ts +4 -0
  193. package/src/core/runner.ts +444 -180
  194. package/src/services/logging/buffer.ts +326 -0
  195. package/src/services/logging/console.ts +193 -0
  196. package/src/services/logging/file-writer.ts +526 -0
  197. package/src/services/logging/formatter.ts +268 -0
  198. package/src/services/logging/index.ts +16 -0
  199. package/src/services/logging/parser.ts +232 -0
  200. package/src/services/process/index.ts +261 -0
  201. package/src/types/agent.ts +24 -0
  202. package/src/types/config.ts +79 -0
  203. package/src/types/events.ts +156 -0
  204. package/src/types/index.ts +29 -0
  205. package/src/types/lane.ts +56 -0
  206. package/src/types/logging.ts +96 -0
  207. package/src/types/review.ts +20 -0
  208. package/src/types/run.ts +37 -0
  209. package/src/types/task.ts +79 -0
  210. package/src/ui/components.ts +430 -0
  211. package/src/ui/log-viewer.ts +485 -0
  212. package/src/utils/checkpoint.ts +374 -0
  213. package/src/utils/config.ts +18 -8
  214. package/src/utils/cursor-agent.ts +1 -1
  215. package/src/utils/dependency.ts +482 -0
  216. package/src/utils/doctor.ts +18 -11
  217. package/src/utils/enhanced-logger.ts +122 -60
  218. package/src/utils/git.ts +517 -11
  219. package/src/utils/health.ts +596 -0
  220. package/src/utils/lock.ts +346 -0
  221. package/src/utils/log-buffer.ts +28 -0
  222. package/src/utils/log-constants.ts +26 -0
  223. package/src/utils/log-formatter.ts +245 -0
  224. package/src/utils/log-service.ts +49 -0
  225. package/src/utils/logger.ts +100 -51
  226. package/src/utils/path.ts +45 -0
  227. package/src/utils/process-manager.ts +100 -0
  228. package/src/utils/retry.ts +413 -0
  229. package/src/utils/run-service.ts +433 -0
  230. package/src/utils/state.ts +385 -11
  231. package/src/utils/task-service.ts +370 -0
  232. package/src/utils/template.ts +92 -0
  233. package/src/utils/types.ts +2 -314
  234. package/templates/basic.json +21 -0
package/src/cli/logs.ts CHANGED
@@ -6,12 +6,15 @@ import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import * as logger from '../utils/logger';
8
8
  import { loadConfig } from '../utils/config';
9
+ import { safeJoin } from '../utils/path';
9
10
  import {
10
11
  readJsonLog,
11
12
  exportLogs,
12
13
  stripAnsi,
13
14
  JsonLogEntry
14
15
  } from '../utils/enhanced-logger';
16
+ import { formatPotentialJsonMessage } from '../utils/log-formatter';
17
+ import { startLogViewer } from '../ui/log-viewer';
15
18
 
16
19
  interface LogsOptions {
17
20
  runDir?: string;
@@ -21,6 +24,7 @@ interface LogsOptions {
21
24
  output?: string;
22
25
  tail?: number;
23
26
  follow: boolean;
27
+ interactive: boolean;
24
28
  filter?: string;
25
29
  level?: string;
26
30
  clean: boolean;
@@ -44,12 +48,14 @@ View and export lane logs.
44
48
 
45
49
  Options:
46
50
  [run-dir] Run directory (default: latest)
51
+ --run <id> Specific run directory
47
52
  --lane <name> Filter to specific lane
48
53
  --all, -a View all lanes merged (sorted by timestamp)
49
54
  --format <fmt> Output format: text, json, markdown, html (default: text)
50
55
  --output <path> Write output to file instead of stdout
51
56
  --tail <n> Show last n lines/entries (default: all)
52
57
  --follow, -f Follow log output in real-time
58
+ --interactive, -i Open interactive log viewer
53
59
  --filter <pattern> Filter entries by regex pattern
54
60
  --level <level> Filter by log level: stdout, stderr, info, error, debug
55
61
  --readable, -r Show readable log (parsed AI output) (default)
@@ -66,23 +72,26 @@ Examples:
66
72
  cursorflow logs --all --format json # Export all lanes as JSON
67
73
  cursorflow logs --all --filter "error" # Filter all lanes for errors
68
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
69
77
  `);
70
78
  }
71
79
 
72
80
  function parseArgs(args: string[]): LogsOptions {
73
81
  const laneIdx = args.indexOf('--lane');
82
+ const runIdx = args.indexOf('--run');
74
83
  const formatIdx = args.indexOf('--format');
75
84
  const outputIdx = args.indexOf('--output');
76
85
  const tailIdx = args.indexOf('--tail');
77
86
  const filterIdx = args.indexOf('--filter');
78
87
  const levelIdx = args.indexOf('--level');
79
88
 
80
- // Find run directory (first non-option argument)
81
- 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) => {
82
91
  if (arg.startsWith('--') || arg.startsWith('-')) return false;
83
92
  // Skip values for options
84
93
  const prevArg = args[i - 1];
85
- if (prevArg && ['--lane', '--format', '--output', '--tail', '--filter', '--level'].includes(prevArg)) {
94
+ if (prevArg && ['--lane', '--run', '--format', '--output', '--tail', '--filter', '--level'].includes(prevArg)) {
86
95
  return false;
87
96
  }
88
97
  return true;
@@ -100,6 +109,7 @@ function parseArgs(args: string[]): LogsOptions {
100
109
  output: outputIdx >= 0 ? args[outputIdx + 1] : undefined,
101
110
  tail: tailIdx >= 0 ? parseInt(args[tailIdx + 1] || '50') : undefined,
102
111
  follow: args.includes('--follow') || args.includes('-f'),
112
+ interactive: args.includes('--interactive') || args.includes('-i'),
103
113
  filter: filterIdx >= 0 ? args[filterIdx + 1] : undefined,
104
114
  level: levelIdx >= 0 ? args[levelIdx + 1] : undefined,
105
115
  raw,
@@ -114,15 +124,15 @@ function parseArgs(args: string[]): LogsOptions {
114
124
  * Find the latest run directory
115
125
  */
116
126
  function findLatestRunDir(logsDir: string): string | null {
117
- const runsDir = path.join(logsDir, 'runs');
127
+ const runsDir = safeJoin(logsDir, 'runs');
118
128
  if (!fs.existsSync(runsDir)) return null;
119
129
 
120
130
  const runs = fs.readdirSync(runsDir)
121
131
  .filter(d => d.startsWith('run-'))
122
132
  .map(d => ({
123
133
  name: d,
124
- path: path.join(runsDir, d),
125
- mtime: fs.statSync(path.join(runsDir, d)).mtime.getTime()
134
+ path: safeJoin(runsDir, d),
135
+ mtime: fs.statSync(safeJoin(runsDir, d)).mtime.getTime()
126
136
  }))
127
137
  .sort((a, b) => b.mtime - a.mtime);
128
138
 
@@ -133,11 +143,11 @@ function findLatestRunDir(logsDir: string): string | null {
133
143
  * List lanes in a run directory
134
144
  */
135
145
  function listLanes(runDir: string): string[] {
136
- const lanesDir = path.join(runDir, 'lanes');
146
+ const lanesDir = safeJoin(runDir, 'lanes');
137
147
  if (!fs.existsSync(lanesDir)) return [];
138
148
 
139
149
  return fs.readdirSync(lanesDir)
140
- .filter(d => fs.statSync(path.join(lanesDir, d)).isDirectory());
150
+ .filter(d => fs.statSync(safeJoin(lanesDir, d)).isDirectory());
141
151
  }
142
152
 
143
153
  /**
@@ -148,9 +158,9 @@ function displayTextLogs(
148
158
  options: LogsOptions
149
159
  ): void {
150
160
  let logFile: string;
151
- const readableLog = path.join(laneDir, 'terminal-readable.log');
152
- const rawLog = path.join(laneDir, 'terminal-raw.log');
153
- const cleanLog = path.join(laneDir, 'terminal.log');
161
+ const readableLog = safeJoin(laneDir, 'terminal-readable.log');
162
+ const rawLog = safeJoin(laneDir, 'terminal-raw.log');
163
+ const cleanLog = safeJoin(laneDir, 'terminal.log');
154
164
 
155
165
  if (options.raw) {
156
166
  logFile = rawLog;
@@ -171,10 +181,10 @@ function displayTextLogs(
171
181
  let content = fs.readFileSync(logFile, 'utf8');
172
182
  let lines = content.split('\n');
173
183
 
174
- // Apply filter (escape to prevent regex injection)
184
+ // Apply filter (case-insensitive string match to avoid ReDoS)
175
185
  if (options.filter) {
176
- const regex = new RegExp(escapeRegex(options.filter), 'i');
177
- lines = lines.filter(line => regex.test(line));
186
+ const filterLower = options.filter.toLowerCase();
187
+ lines = lines.filter(line => line.toLowerCase().includes(filterLower));
178
188
  }
179
189
 
180
190
  // Apply tail
@@ -197,7 +207,7 @@ function displayJsonLogs(
197
207
  laneDir: string,
198
208
  options: LogsOptions
199
209
  ): void {
200
- const logFile = path.join(laneDir, 'terminal.jsonl');
210
+ const logFile = safeJoin(laneDir, 'terminal.jsonl');
201
211
 
202
212
  if (!fs.existsSync(logFile)) {
203
213
  console.log('No JSON log file found.');
@@ -211,10 +221,13 @@ function displayJsonLogs(
211
221
  entries = entries.filter(e => e.level === options.level);
212
222
  }
213
223
 
214
- // Apply regex filter (escape to prevent regex injection)
224
+ // Apply filter (case-insensitive string match to avoid ReDoS)
215
225
  if (options.filter) {
216
- const regex = new RegExp(escapeRegex(options.filter), 'i');
217
- entries = entries.filter(e => regex.test(e.message) || regex.test(e.task || ''));
226
+ const filterLower = options.filter.toLowerCase();
227
+ entries = entries.filter(e =>
228
+ (e.message || '').toLowerCase().includes(filterLower) ||
229
+ (e.task && e.task.toLowerCase().includes(filterLower))
230
+ );
218
231
  }
219
232
 
220
233
  // Apply tail
@@ -227,9 +240,12 @@ function displayJsonLogs(
227
240
  } else {
228
241
  // Display as formatted text
229
242
  for (const entry of entries) {
230
- const levelColor = getLevelColor(entry.level);
243
+ const level = entry.level || 'info';
244
+ const message = entry.message || '';
245
+ const levelColor = getLevelColor(level);
231
246
  const ts = new Date(entry.timestamp).toLocaleTimeString();
232
- console.log(`${levelColor}[${ts}] [${entry.level.toUpperCase().padEnd(6)}]${logger.COLORS.reset} ${entry.message}`);
247
+ const formattedMsg = formatPotentialJsonMessage(message);
248
+ console.log(`${levelColor}[${ts}] [${level.toUpperCase().padEnd(6)}]${logger.COLORS.reset} ${formattedMsg}`);
233
249
  }
234
250
  }
235
251
  }
@@ -290,8 +306,8 @@ function readAllLaneLogs(runDir: string): MergedLogEntry[] {
290
306
  const allEntries: MergedLogEntry[] = [];
291
307
 
292
308
  lanes.forEach((laneName, index) => {
293
- const laneDir = path.join(runDir, 'lanes', laneName);
294
- const jsonLogPath = path.join(laneDir, 'terminal.jsonl');
309
+ const laneDir = safeJoin(runDir, 'lanes', laneName);
310
+ const jsonLogPath = safeJoin(laneDir, 'terminal.jsonl');
295
311
 
296
312
  if (fs.existsSync(jsonLogPath)) {
297
313
  const entries = readJsonLog(jsonLogPath);
@@ -333,13 +349,13 @@ function displayMergedLogs(runDir: string, options: LogsOptions): void {
333
349
  entries = entries.filter(e => e.level === options.level);
334
350
  }
335
351
 
336
- // Apply regex filter (escape to prevent regex injection)
352
+ // Apply filter (case-insensitive string match to avoid ReDoS)
337
353
  if (options.filter) {
338
- const regex = new RegExp(escapeRegex(options.filter), 'i');
354
+ const filterLower = options.filter.toLowerCase();
339
355
  entries = entries.filter(e =>
340
- regex.test(e.message) ||
341
- regex.test(e.task || '') ||
342
- regex.test(e.laneName)
356
+ (e.message || '').toLowerCase().includes(filterLower) ||
357
+ (e.task && e.task.toLowerCase().includes(filterLower)) ||
358
+ e.laneName.toLowerCase().includes(filterLower)
343
359
  );
344
360
  }
345
361
 
@@ -372,22 +388,26 @@ function displayMergedLogs(runDir: string, options: LogsOptions): void {
372
388
  // Display entries
373
389
  for (const entry of entries) {
374
390
  const ts = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false });
375
- const levelColor = getLevelColor(entry.level);
391
+ const level = entry.level || 'info';
392
+ const levelColor = getLevelColor(level);
376
393
  const laneColor = entry.laneColor;
377
394
  const lanePad = entry.laneName.substring(0, 12).padEnd(12);
378
- const levelPad = entry.level.toUpperCase().padEnd(6);
395
+ const levelPad = level.toUpperCase().padEnd(6);
396
+
397
+ const message = entry.message || '';
379
398
 
380
399
  // Skip session entries for cleaner output unless they're important
381
- if (entry.level === 'session' && entry.message === 'Session started') {
400
+ if (level === 'session' && message === 'Session started') {
382
401
  console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${laneColor}[${lanePad}]${logger.COLORS.reset} ${logger.COLORS.cyan}── Session Started ──${logger.COLORS.reset}`);
383
402
  continue;
384
403
  }
385
- if (entry.level === 'session' && entry.message === 'Session ended') {
404
+ if (level === 'session' && message === 'Session ended') {
386
405
  console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${laneColor}[${lanePad}]${logger.COLORS.reset} ${logger.COLORS.cyan}── Session Ended ──${logger.COLORS.reset}`);
387
406
  continue;
388
407
  }
389
408
 
390
- console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${entry.message}`);
409
+ const formattedMsg = formatPotentialJsonMessage(message);
410
+ console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${formattedMsg}`);
391
411
  }
392
412
 
393
413
  console.log(`\n${logger.COLORS.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${logger.COLORS.reset}`);
@@ -426,17 +446,17 @@ function followAllLogs(runDir: string, options: LogsOptions): void {
426
446
  const newEntries: MergedLogEntry[] = [];
427
447
 
428
448
  for (const lane of lanes) {
429
- const laneDir = path.join(runDir, 'lanes', lane);
430
- const jsonLogPath = path.join(laneDir, 'terminal.jsonl');
449
+ const laneDir = safeJoin(runDir, 'lanes', lane);
450
+ const jsonLogPath = safeJoin(laneDir, 'terminal.jsonl');
431
451
 
452
+ let fd: number | null = null;
432
453
  try {
433
- // Use statSync directly to avoid TOCTOU race condition
434
- 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);
435
457
  if (stats.size > lastPositions[lane]!) {
436
- const fd = fs.openSync(jsonLogPath, 'r');
437
458
  const buffer = Buffer.alloc(stats.size - lastPositions[lane]!);
438
459
  fs.readSync(fd, buffer, 0, buffer.length, lastPositions[lane]!);
439
- fs.closeSync(fd);
440
460
 
441
461
  const content = buffer.toString();
442
462
  const lines = content.split('\n').filter(l => l.trim());
@@ -458,6 +478,10 @@ function followAllLogs(runDir: string, options: LogsOptions): void {
458
478
  }
459
479
  } catch {
460
480
  // Ignore errors
481
+ } finally {
482
+ if (fd !== null) {
483
+ try { fs.closeSync(fd); } catch { /* ignore */ }
484
+ }
461
485
  }
462
486
  }
463
487
 
@@ -470,33 +494,39 @@ function followAllLogs(runDir: string, options: LogsOptions): void {
470
494
 
471
495
  // Apply filters and display
472
496
  for (let entry of newEntries) {
497
+ const level = entry.level || 'info';
498
+ const message = entry.message || '';
499
+
473
500
  // Apply level filter
474
- if (options.level && entry.level !== options.level) continue;
501
+ if (options.level && level !== options.level) continue;
475
502
 
476
- // Apply regex filter (escape to prevent regex injection)
503
+ // Apply filter (case-insensitive string match to avoid ReDoS)
477
504
  if (options.filter) {
478
- const regex = new RegExp(escapeRegex(options.filter), 'i');
479
- if (!regex.test(entry.message) && !regex.test(entry.task || '') && !regex.test(entry.laneName)) {
505
+ const filterLower = options.filter.toLowerCase();
506
+ if (!message.toLowerCase().includes(filterLower) &&
507
+ !(entry.task && entry.task.toLowerCase().includes(filterLower)) &&
508
+ !entry.laneName.toLowerCase().includes(filterLower)) {
480
509
  continue;
481
510
  }
482
511
  }
483
512
 
484
513
  const ts = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false });
485
- const levelColor = getLevelColor(entry.level);
514
+ const levelColor = getLevelColor(level);
486
515
  const lanePad = entry.laneName.substring(0, 12).padEnd(12);
487
- const levelPad = entry.level.toUpperCase().padEnd(6);
516
+ const levelPad = level.toUpperCase().padEnd(6);
488
517
 
489
518
  // Skip verbose session entries
490
- if (entry.level === 'session') {
491
- if (entry.message === 'Session started') {
519
+ if (level === 'session') {
520
+ if (message === 'Session started') {
492
521
  console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${entry.laneColor}[${lanePad}]${logger.COLORS.reset} ${logger.COLORS.cyan}── Session Started ──${logger.COLORS.reset}`);
493
- } else if (entry.message === 'Session ended') {
522
+ } else if (message === 'Session ended') {
494
523
  console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${entry.laneColor}[${lanePad}]${logger.COLORS.reset} ${logger.COLORS.cyan}── Session Ended ──${logger.COLORS.reset}`);
495
524
  }
496
525
  continue;
497
526
  }
498
527
 
499
- console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${entry.laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${entry.message}`);
528
+ const formattedMsg = formatPotentialJsonMessage(message);
529
+ console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${entry.laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${formattedMsg}`);
500
530
  }
501
531
  }, 100);
502
532
 
@@ -532,7 +562,9 @@ function exportMergedLogs(runDir: string, format: string, outputPath?: string):
532
562
  // Text format
533
563
  for (const entry of entries) {
534
564
  const ts = new Date(entry.timestamp).toISOString();
535
- 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`;
536
568
  }
537
569
  }
538
570
 
@@ -560,8 +592,14 @@ function exportMergedToMarkdown(entries: MergedLogEntry[], runDir: string): stri
560
592
 
561
593
  for (const entry of entries) {
562
594
  const ts = new Date(entry.timestamp).toLocaleTimeString();
563
- const message = entry.message.replace(/\|/g, '\\|').substring(0, 80);
564
- 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`;
565
603
  }
566
604
 
567
605
  return md;
@@ -615,12 +653,14 @@ function exportMergedToHtml(entries: MergedLogEntry[], runDir: string): string {
615
653
  const ts = new Date(entry.timestamp).toLocaleTimeString();
616
654
  const laneIndex = lanes.indexOf(entry.laneName);
617
655
  const color = colors[laneIndex % colors.length];
656
+ const level = entry.level || 'info';
657
+ const message = entry.message || '';
618
658
 
619
- html += ` <div class="entry ${entry.level}">
659
+ html += ` <div class="entry ${level}">
620
660
  <span class="time">${ts}</span>
621
661
  <span class="lane" style="color: ${color}">${entry.laneName}</span>
622
- <span class="level">[${entry.level.toUpperCase()}]</span>
623
- <span class="message">${escapeHtml(entry.message)}</span>
662
+ <span class="level">[${level.toUpperCase()}]</span>
663
+ <span class="message">${escapeHtml(message)}</span>
624
664
  </div>\n`;
625
665
  }
626
666
 
@@ -644,9 +684,9 @@ function escapeHtml(text: string): string {
644
684
  */
645
685
  function followLogs(laneDir: string, options: LogsOptions): void {
646
686
  let logFile: string;
647
- const readableLog = path.join(laneDir, 'terminal-readable.log');
648
- const rawLog = path.join(laneDir, 'terminal-raw.log');
649
- const cleanLog = path.join(laneDir, 'terminal.log');
687
+ const readableLog = safeJoin(laneDir, 'terminal-readable.log');
688
+ const rawLog = safeJoin(laneDir, 'terminal-raw.log');
689
+ const cleanLog = safeJoin(laneDir, 'terminal.log');
650
690
 
651
691
  if (options.raw) {
652
692
  logFile = rawLog;
@@ -675,23 +715,22 @@ function followLogs(laneDir: string, options: LogsOptions): void {
675
715
  console.log(`${logger.COLORS.cyan}Following ${logFile}... (Ctrl+C to stop)${logger.COLORS.reset}\n`);
676
716
 
677
717
  const checkInterval = setInterval(() => {
718
+ // Use fstat on open fd to avoid TOCTOU race condition
719
+ let fd: number | null = null;
678
720
  try {
679
- if (!fs.existsSync(logFile)) return;
680
-
681
- const stats = fs.statSync(logFile);
721
+ fd = fs.openSync(logFile, 'r');
722
+ const stats = fs.fstatSync(fd);
682
723
  if (stats.size > lastSize) {
683
- const fd = fs.openSync(logFile, 'r');
684
724
  const buffer = Buffer.alloc(stats.size - lastSize);
685
725
  fs.readSync(fd, buffer, 0, buffer.length, lastSize);
686
- fs.closeSync(fd);
687
726
 
688
727
  let content = buffer.toString();
689
728
 
690
- // Apply filter (escape to prevent regex injection)
729
+ // Apply filter (case-insensitive string match to avoid ReDoS)
691
730
  if (options.filter) {
692
- const regex = new RegExp(escapeRegex(options.filter), 'i');
731
+ const filterLower = options.filter.toLowerCase();
693
732
  const lines = content.split('\n');
694
- content = lines.filter(line => regex.test(line)).join('\n');
733
+ content = lines.filter(line => line.toLowerCase().includes(filterLower)).join('\n');
695
734
  }
696
735
 
697
736
  // Clean ANSI if needed (unless raw mode)
@@ -705,8 +744,12 @@ function followLogs(laneDir: string, options: LogsOptions): void {
705
744
 
706
745
  lastSize = stats.size;
707
746
  }
708
- } catch (e) {
747
+ } catch {
709
748
  // Ignore errors (file might be rotating)
749
+ } finally {
750
+ if (fd !== null) {
751
+ try { fs.closeSync(fd); } catch { /* ignore */ }
752
+ }
710
753
  }
711
754
  }, 100);
712
755
 
@@ -734,11 +777,11 @@ function displaySummary(runDir: string): void {
734
777
  }
735
778
 
736
779
  for (const lane of lanes) {
737
- const laneDir = path.join(runDir, 'lanes', lane);
738
- const cleanLog = path.join(laneDir, 'terminal.log');
739
- const rawLog = path.join(laneDir, 'terminal-raw.log');
740
- const jsonLog = path.join(laneDir, 'terminal.jsonl');
741
- const readableLog = path.join(laneDir, 'terminal-readable.log');
780
+ const laneDir = safeJoin(runDir, 'lanes', lane);
781
+ const cleanLog = safeJoin(laneDir, 'terminal.log');
782
+ const rawLog = safeJoin(laneDir, 'terminal-raw.log');
783
+ const jsonLog = safeJoin(laneDir, 'terminal.jsonl');
784
+ const readableLog = safeJoin(laneDir, 'terminal-readable.log');
742
785
 
743
786
  console.log(` ${logger.COLORS.green}📁 ${lane}${logger.COLORS.reset}`);
744
787
 
@@ -794,13 +837,17 @@ async function logs(args: string[]): Promise<void> {
794
837
  let runDir = options.runDir;
795
838
  if (!runDir || runDir === 'latest') {
796
839
  runDir = findLatestRunDir(config.logsDir) || undefined;
797
- if (!runDir) {
798
- throw new Error('No run directories found');
799
- }
800
840
  }
801
841
 
802
- if (!fs.existsSync(runDir)) {
803
- 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;
804
851
  }
805
852
 
806
853
  // Handle --all option (view all lanes merged)
@@ -839,7 +886,7 @@ async function logs(args: string[]): Promise<void> {
839
886
  }
840
887
 
841
888
  // Find lane directory
842
- const laneDir = path.join(runDir, 'lanes', options.lane);
889
+ const laneDir = safeJoin(runDir, 'lanes', options.lane);
843
890
  if (!fs.existsSync(laneDir)) {
844
891
  const lanes = listLanes(runDir);
845
892
  throw new Error(`Lane not found: ${options.lane}\nAvailable lanes: ${lanes.join(', ')}`);