@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,1097 @@
1
+ /**
2
+ * Enhanced Logger - Comprehensive terminal output capture and management
3
+ *
4
+ * Features:
5
+ * - ANSI escape sequence stripping for clean logs
6
+ * - Automatic timestamps on each line
7
+ * - Log rotation and size management
8
+ * - Session headers with context
9
+ * - Raw and clean log file options
10
+ * - Structured JSON logs for programmatic access
11
+ * - Streaming output support for real-time capture
12
+ */
13
+
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+ import { PassThrough, Transform, TransformCallback } from 'stream';
17
+ import { EnhancedLogConfig } from './types';
18
+
19
+ // Re-export for backwards compatibility
20
+ export { EnhancedLogConfig } from './types';
21
+
22
+ export const DEFAULT_LOG_CONFIG: EnhancedLogConfig = {
23
+ enabled: true,
24
+ stripAnsi: true,
25
+ addTimestamps: true,
26
+ maxFileSize: 50 * 1024 * 1024, // 50MB
27
+ maxFiles: 5,
28
+ keepRawLogs: true,
29
+ writeJsonLog: true,
30
+ timestampFormat: 'iso',
31
+ };
32
+
33
+ /**
34
+ * Streaming JSON Parser - Parses cursor-agent stream-json output
35
+ * and combines tokens into readable messages
36
+ */
37
+ export class StreamingMessageParser {
38
+ private currentMessage: string = '';
39
+ private currentRole: string = '';
40
+ private messageStartTime: number = 0;
41
+ private onMessage: (msg: ParsedMessage) => void;
42
+
43
+ constructor(onMessage: (msg: ParsedMessage) => void) {
44
+ this.onMessage = onMessage;
45
+ }
46
+
47
+ /**
48
+ * Parse a line of JSON output from cursor-agent
49
+ */
50
+ parseLine(line: string): void {
51
+ const trimmed = line.trim();
52
+ if (!trimmed || !trimmed.startsWith('{')) return;
53
+
54
+ try {
55
+ const json = JSON.parse(trimmed);
56
+ this.handleJsonMessage(json);
57
+ } catch {
58
+ // Not valid JSON, ignore
59
+ }
60
+ }
61
+
62
+ private handleJsonMessage(json: any): void {
63
+ const type = json.type;
64
+
65
+ switch (type) {
66
+ case 'system':
67
+ // System init message
68
+ this.emitMessage({
69
+ type: 'system',
70
+ role: 'system',
71
+ content: `[System] Model: ${json.model || 'unknown'}, Mode: ${json.permissionMode || 'default'}`,
72
+ timestamp: json.timestamp_ms || Date.now(),
73
+ });
74
+ break;
75
+
76
+ case 'user':
77
+ // User message - emit as complete message
78
+ if (json.message?.content) {
79
+ const textContent = json.message.content
80
+ .filter((c: any) => c.type === 'text')
81
+ .map((c: any) => c.text)
82
+ .join('');
83
+
84
+ this.emitMessage({
85
+ type: 'user',
86
+ role: 'user',
87
+ content: textContent,
88
+ timestamp: json.timestamp_ms || Date.now(),
89
+ });
90
+ }
91
+ break;
92
+
93
+ case 'assistant':
94
+ // Streaming assistant message - accumulate tokens
95
+ if (json.message?.content) {
96
+ const textContent = json.message.content
97
+ .filter((c: any) => c.type === 'text')
98
+ .map((c: any) => c.text)
99
+ .join('');
100
+
101
+ // Check if this is a new message or continuation
102
+ if (this.currentRole !== 'assistant') {
103
+ // Flush previous message if any
104
+ this.flush();
105
+ this.currentRole = 'assistant';
106
+ this.messageStartTime = json.timestamp_ms || Date.now();
107
+ }
108
+
109
+ this.currentMessage += textContent;
110
+ }
111
+ break;
112
+
113
+ case 'tool_call':
114
+ // Tool call - emit as formatted message
115
+ if (json.subtype === 'started' && json.tool_call) {
116
+ const toolName = Object.keys(json.tool_call)[0] || 'unknown';
117
+ const toolArgs = json.tool_call[toolName]?.args || {};
118
+
119
+ this.flush(); // Flush any pending assistant message
120
+
121
+ this.emitMessage({
122
+ type: 'tool',
123
+ role: 'tool',
124
+ content: `[Tool: ${toolName}] ${JSON.stringify(toolArgs)}`,
125
+ timestamp: json.timestamp_ms || Date.now(),
126
+ metadata: { callId: json.call_id, toolName },
127
+ });
128
+ } else if (json.subtype === 'completed' && json.tool_call) {
129
+ const toolName = Object.keys(json.tool_call)[0] || 'unknown';
130
+ const result = json.tool_call[toolName]?.result;
131
+
132
+ if (result?.success) {
133
+ // Truncate large results
134
+ const content = result.success.content || '';
135
+ const truncated = content.length > 500
136
+ ? content.substring(0, 500) + '... (truncated)'
137
+ : content;
138
+
139
+ this.emitMessage({
140
+ type: 'tool_result',
141
+ role: 'tool',
142
+ content: `[Tool Result: ${toolName}] ${truncated}`,
143
+ timestamp: json.timestamp_ms || Date.now(),
144
+ metadata: { callId: json.call_id, toolName, lines: result.success.totalLines },
145
+ });
146
+ }
147
+ }
148
+ break;
149
+
150
+ case 'result':
151
+ // Final result - flush any pending and emit result
152
+ this.flush();
153
+
154
+ this.emitMessage({
155
+ type: 'result',
156
+ role: 'assistant',
157
+ content: json.result || '',
158
+ timestamp: json.timestamp_ms || Date.now(),
159
+ metadata: {
160
+ duration_ms: json.duration_ms,
161
+ is_error: json.is_error,
162
+ subtype: json.subtype,
163
+ },
164
+ });
165
+ break;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Flush accumulated message
171
+ */
172
+ flush(): void {
173
+ if (this.currentMessage && this.currentRole) {
174
+ this.emitMessage({
175
+ type: this.currentRole as any,
176
+ role: this.currentRole,
177
+ content: this.currentMessage,
178
+ timestamp: this.messageStartTime,
179
+ });
180
+ }
181
+ this.currentMessage = '';
182
+ this.currentRole = '';
183
+ this.messageStartTime = 0;
184
+ }
185
+
186
+ private emitMessage(msg: ParsedMessage): void {
187
+ if (msg.content.trim()) {
188
+ this.onMessage(msg);
189
+ }
190
+ }
191
+ }
192
+
193
+ export interface ParsedMessage {
194
+ type: 'system' | 'user' | 'assistant' | 'tool' | 'tool_result' | 'result';
195
+ role: string;
196
+ content: string;
197
+ timestamp: number;
198
+ metadata?: Record<string, any>;
199
+ }
200
+
201
+ /**
202
+ * ANSI escape sequence regex pattern
203
+ * Matches:
204
+ * - CSI sequences (colors, cursor movement, etc.)
205
+ * - OSC sequences (terminal titles, etc.)
206
+ * - Single-character escape codes
207
+ */
208
+ const ANSI_REGEX = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
209
+
210
+ /**
211
+ * Extended ANSI regex for more complete stripping
212
+ */
213
+ const EXTENDED_ANSI_REGEX = /(?:\x1B[@-Z\\-_]|\x1B\[[0-?]*[ -/]*[@-~]|\x1B\][^\x07]*(?:\x07|\x1B\\)|\x1B[PX^_][^\x1B]*\x1B\\|\x1B.)/g;
214
+
215
+ /**
216
+ * Strip ANSI escape sequences from text
217
+ */
218
+ export function stripAnsi(text: string): string {
219
+ return text
220
+ .replace(EXTENDED_ANSI_REGEX, '')
221
+ .replace(ANSI_REGEX, '')
222
+ // Also remove carriage returns that overwrite lines (progress bars, etc.)
223
+ .replace(/\r[^\n]/g, '\n')
224
+ // Clean up any remaining control characters except newlines/tabs
225
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
226
+ }
227
+
228
+ /**
229
+ * Format timestamp based on format preference
230
+ */
231
+ export function formatTimestamp(format: 'iso' | 'relative' | 'short', startTime?: number): string {
232
+ const now = Date.now();
233
+
234
+ switch (format) {
235
+ case 'iso':
236
+ return new Date(now).toISOString();
237
+ case 'relative':
238
+ if (startTime) {
239
+ const elapsed = now - startTime;
240
+ const seconds = Math.floor(elapsed / 1000);
241
+ const minutes = Math.floor(seconds / 60);
242
+ const hours = Math.floor(minutes / 60);
243
+
244
+ if (hours > 0) {
245
+ return `+${hours}h${minutes % 60}m${seconds % 60}s`;
246
+ } else if (minutes > 0) {
247
+ return `+${minutes}m${seconds % 60}s`;
248
+ } else {
249
+ return `+${seconds}s`;
250
+ }
251
+ }
252
+ return new Date(now).toISOString();
253
+ case 'short':
254
+ return new Date(now).toLocaleTimeString('en-US', { hour12: false });
255
+ default:
256
+ return new Date(now).toISOString();
257
+ }
258
+ }
259
+
260
+ /**
261
+ * JSON log entry structure
262
+ */
263
+ export interface JsonLogEntry {
264
+ timestamp: string;
265
+ level: 'stdout' | 'stderr' | 'info' | 'error' | 'debug' | 'session';
266
+ source?: string;
267
+ task?: string;
268
+ lane?: string;
269
+ message: string;
270
+ raw?: string;
271
+ metadata?: Record<string, any>;
272
+ }
273
+
274
+ /**
275
+ * Session context for logging
276
+ */
277
+ export interface LogSession {
278
+ id: string;
279
+ laneName: string;
280
+ taskName?: string;
281
+ model?: string;
282
+ startTime: number;
283
+ metadata?: Record<string, any>;
284
+ }
285
+
286
+ /**
287
+ * Regex to detect if a line already has an ISO timestamp at the start
288
+ */
289
+ const EXISTING_TIMESTAMP_REGEX = /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
290
+
291
+ /**
292
+ * Check if a line already has a timestamp
293
+ */
294
+ function hasExistingTimestamp(line: string): boolean {
295
+ return EXISTING_TIMESTAMP_REGEX.test(line.trim());
296
+ }
297
+
298
+ /**
299
+ * Transform stream that strips ANSI and adds timestamps
300
+ */
301
+ export class CleanLogTransform extends Transform {
302
+ private config: EnhancedLogConfig;
303
+ private session: LogSession;
304
+ private buffer: string = '';
305
+
306
+ constructor(config: EnhancedLogConfig, session: LogSession) {
307
+ super({ encoding: 'utf8' });
308
+ this.config = config;
309
+ this.session = session;
310
+ }
311
+
312
+ _transform(chunk: Buffer, encoding: BufferEncoding, callback: TransformCallback): void {
313
+ let text = chunk.toString();
314
+
315
+ // Buffer partial lines
316
+ this.buffer += text;
317
+ const lines = this.buffer.split('\n');
318
+
319
+ // Keep the last incomplete line in buffer
320
+ this.buffer = lines.pop() || '';
321
+
322
+ for (const line of lines) {
323
+ let processed = line;
324
+
325
+ // Strip ANSI if enabled
326
+ if (this.config.stripAnsi) {
327
+ processed = stripAnsi(processed);
328
+ }
329
+
330
+ // Add timestamp if enabled AND line doesn't already have one
331
+ if (this.config.addTimestamps && processed.trim() && !hasExistingTimestamp(processed)) {
332
+ const ts = formatTimestamp(this.config.timestampFormat, this.session.startTime);
333
+ processed = `[${ts}] ${processed}`;
334
+ }
335
+
336
+ this.push(processed + '\n');
337
+ }
338
+
339
+ callback();
340
+ }
341
+
342
+ _flush(callback: TransformCallback): void {
343
+ // Process any remaining buffered content
344
+ if (this.buffer.trim()) {
345
+ let processed = this.buffer;
346
+
347
+ if (this.config.stripAnsi) {
348
+ processed = stripAnsi(processed);
349
+ }
350
+
351
+ if (this.config.addTimestamps && processed.trim() && !hasExistingTimestamp(processed)) {
352
+ const ts = formatTimestamp(this.config.timestampFormat, this.session.startTime);
353
+ processed = `[${ts}] ${processed}`;
354
+ }
355
+
356
+ this.push(processed + '\n');
357
+ }
358
+
359
+ callback();
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Enhanced Log Manager - Manages log files with rotation and multiple outputs
365
+ */
366
+ export class EnhancedLogManager {
367
+ private config: EnhancedLogConfig;
368
+ private session: LogSession;
369
+ private logDir: string;
370
+
371
+ private cleanLogPath: string;
372
+ private rawLogPath: string;
373
+ private jsonLogPath: string;
374
+ private readableLogPath: string;
375
+
376
+ private cleanLogFd: number | null = null;
377
+ private rawLogFd: number | null = null;
378
+ private jsonLogFd: number | null = null;
379
+ private readableLogFd: number | null = null;
380
+
381
+ private cleanLogSize: number = 0;
382
+ private rawLogSize: number = 0;
383
+
384
+ private cleanTransform: CleanLogTransform | null = null;
385
+ private streamingParser: StreamingMessageParser | null = null;
386
+ private lineBuffer: string = '';
387
+
388
+ constructor(logDir: string, session: LogSession, config: Partial<EnhancedLogConfig> = {}) {
389
+ this.config = { ...DEFAULT_LOG_CONFIG, ...config };
390
+ this.session = session;
391
+ this.logDir = logDir;
392
+
393
+ // Ensure log directory exists
394
+ fs.mkdirSync(logDir, { recursive: true });
395
+
396
+ // Set up log file paths
397
+ this.cleanLogPath = path.join(logDir, 'terminal.log');
398
+ this.rawLogPath = path.join(logDir, 'terminal-raw.log');
399
+ this.jsonLogPath = path.join(logDir, 'terminal.jsonl');
400
+ this.readableLogPath = path.join(logDir, 'terminal-readable.log');
401
+
402
+ // Initialize log files
403
+ this.initLogFiles();
404
+ }
405
+
406
+ /**
407
+ * Initialize log files and write session headers
408
+ */
409
+ private initLogFiles(): void {
410
+ // Check and rotate if necessary
411
+ this.rotateIfNeeded(this.cleanLogPath, 'clean');
412
+ if (this.config.keepRawLogs) {
413
+ this.rotateIfNeeded(this.rawLogPath, 'raw');
414
+ }
415
+
416
+ // Open file descriptors
417
+ this.cleanLogFd = fs.openSync(this.cleanLogPath, 'a');
418
+
419
+ if (this.config.keepRawLogs) {
420
+ this.rawLogFd = fs.openSync(this.rawLogPath, 'a');
421
+ }
422
+
423
+ if (this.config.writeJsonLog) {
424
+ this.jsonLogFd = fs.openSync(this.jsonLogPath, 'a');
425
+ }
426
+
427
+ // Open readable log file (for parsed streaming output)
428
+ this.readableLogFd = fs.openSync(this.readableLogPath, 'a');
429
+
430
+ // Get initial file sizes
431
+ try {
432
+ this.cleanLogSize = fs.statSync(this.cleanLogPath).size;
433
+ if (this.config.keepRawLogs) {
434
+ this.rawLogSize = fs.statSync(this.rawLogPath).size;
435
+ }
436
+ } catch {
437
+ this.cleanLogSize = 0;
438
+ this.rawLogSize = 0;
439
+ }
440
+
441
+ // Write session header
442
+ this.writeSessionHeader();
443
+
444
+ // Create transform stream
445
+ this.cleanTransform = new CleanLogTransform(this.config, this.session);
446
+ this.cleanTransform.on('data', (data: string) => {
447
+ this.writeToCleanLog(data);
448
+ });
449
+
450
+ // Create streaming parser for readable log
451
+ this.streamingParser = new StreamingMessageParser((msg) => {
452
+ this.writeReadableMessage(msg);
453
+ });
454
+ }
455
+
456
+ /**
457
+ * Write a parsed message to the readable log
458
+ */
459
+ private writeReadableMessage(msg: ParsedMessage): void {
460
+ if (this.readableLogFd === null) return;
461
+
462
+ const ts = new Date(msg.timestamp).toISOString();
463
+ let formatted: string;
464
+
465
+ switch (msg.type) {
466
+ case 'system':
467
+ formatted = `\n${ts}\n${msg.content}\n`;
468
+ break;
469
+
470
+ case 'user':
471
+ // Format user prompt nicely
472
+ const promptPreview = msg.content.length > 200
473
+ ? msg.content.substring(0, 200) + '...'
474
+ : msg.content;
475
+ formatted = `\n${ts}\n┌─ 🧑 USER ─────────────────────────────────────────────\n${this.indentText(promptPreview, '│ ')}\n└───────────────────────────────────────────────────────\n`;
476
+ break;
477
+
478
+ case 'assistant':
479
+ case 'result':
480
+ // Format assistant response
481
+ const isResult = msg.type === 'result';
482
+ const header = isResult ? '🤖 ASSISTANT (Final)' : '🤖 ASSISTANT';
483
+ const duration = msg.metadata?.duration_ms
484
+ ? ` (${Math.round(msg.metadata.duration_ms / 1000)}s)`
485
+ : '';
486
+ formatted = `\n${ts}\n┌─ ${header}${duration} ──────────────────────────────────\n${this.indentText(msg.content, '│ ')}\n└───────────────────────────────────────────────────────\n`;
487
+ break;
488
+
489
+ case 'tool':
490
+ // Format tool call
491
+ formatted = `${ts} 🔧 ${msg.content}\n`;
492
+ break;
493
+
494
+ case 'tool_result':
495
+ // Format tool result (truncated)
496
+ const lines = msg.metadata?.lines ? ` (${msg.metadata.lines} lines)` : '';
497
+ formatted = `${ts} 📄 ${msg.metadata?.toolName || 'Tool'}${lines}\n`;
498
+ break;
499
+
500
+ default:
501
+ formatted = `${ts} ${msg.content}\n`;
502
+ }
503
+
504
+ try {
505
+ fs.writeSync(this.readableLogFd, formatted);
506
+ } catch {
507
+ // Ignore write errors
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Indent text with a prefix
513
+ */
514
+ private indentText(text: string, prefix: string): string {
515
+ return text.split('\n').map(line => prefix + line).join('\n');
516
+ }
517
+
518
+ /**
519
+ * Write session header to logs
520
+ */
521
+ private writeSessionHeader(): void {
522
+ const header = `
523
+ ╔══════════════════════════════════════════════════════════════════════════════╗
524
+ ║ CursorFlow Session Log ║
525
+ ╠══════════════════════════════════════════════════════════════════════════════╣
526
+ ║ Session ID: ${this.session.id.padEnd(62)}║
527
+ ║ Lane: ${this.session.laneName.padEnd(62)}║
528
+ ║ Task: ${(this.session.taskName || '-').padEnd(62)}║
529
+ ║ Model: ${(this.session.model || 'default').padEnd(62)}║
530
+ ║ Started: ${new Date(this.session.startTime).toISOString().padEnd(62)}║
531
+ ╚══════════════════════════════════════════════════════════════════════════════╝
532
+
533
+ `;
534
+
535
+ this.writeToCleanLog(header);
536
+
537
+ if (this.config.keepRawLogs && this.rawLogFd !== null) {
538
+ fs.writeSync(this.rawLogFd, header);
539
+ }
540
+
541
+ // Write JSON session entry
542
+ this.writeJsonEntry({
543
+ timestamp: new Date(this.session.startTime).toISOString(),
544
+ level: 'session',
545
+ source: 'system',
546
+ lane: this.session.laneName,
547
+ task: this.session.taskName,
548
+ message: 'Session started',
549
+ metadata: {
550
+ sessionId: this.session.id,
551
+ model: this.session.model,
552
+ ...this.session.metadata,
553
+ },
554
+ });
555
+ }
556
+
557
+ /**
558
+ * Rotate log file if it exceeds max size
559
+ */
560
+ private rotateIfNeeded(logPath: string, type: 'clean' | 'raw'): void {
561
+ if (!fs.existsSync(logPath)) return;
562
+
563
+ try {
564
+ const stats = fs.statSync(logPath);
565
+ if (stats.size >= this.config.maxFileSize) {
566
+ this.rotateLog(logPath);
567
+ }
568
+ } catch {
569
+ // File doesn't exist or can't be read, ignore
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Rotate a log file
575
+ */
576
+ private rotateLog(logPath: string): void {
577
+ const dir = path.dirname(logPath);
578
+ const ext = path.extname(logPath);
579
+ const base = path.basename(logPath, ext);
580
+
581
+ // Shift existing rotated files
582
+ for (let i = this.config.maxFiles - 1; i >= 1; i--) {
583
+ const oldPath = path.join(dir, `${base}.${i}${ext}`);
584
+ const newPath = path.join(dir, `${base}.${i + 1}${ext}`);
585
+
586
+ if (fs.existsSync(oldPath)) {
587
+ if (i === this.config.maxFiles - 1) {
588
+ fs.unlinkSync(oldPath); // Delete oldest
589
+ } else {
590
+ fs.renameSync(oldPath, newPath);
591
+ }
592
+ }
593
+ }
594
+
595
+ // Rotate current to .1
596
+ const rotatedPath = path.join(dir, `${base}.1${ext}`);
597
+ fs.renameSync(logPath, rotatedPath);
598
+ }
599
+
600
+ /**
601
+ * Write to clean log with size tracking
602
+ */
603
+ private writeToCleanLog(data: string): void {
604
+ if (this.cleanLogFd === null) return;
605
+
606
+ const buffer = Buffer.from(data);
607
+ fs.writeSync(this.cleanLogFd, buffer);
608
+ this.cleanLogSize += buffer.length;
609
+
610
+ // Check if rotation needed
611
+ if (this.cleanLogSize >= this.config.maxFileSize) {
612
+ fs.closeSync(this.cleanLogFd);
613
+ this.rotateLog(this.cleanLogPath);
614
+ this.cleanLogFd = fs.openSync(this.cleanLogPath, 'a');
615
+ this.cleanLogSize = 0;
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Write to raw log with size tracking
621
+ */
622
+ private writeToRawLog(data: string): void {
623
+ if (this.rawLogFd === null) return;
624
+
625
+ const buffer = Buffer.from(data);
626
+ fs.writeSync(this.rawLogFd, buffer);
627
+ this.rawLogSize += buffer.length;
628
+
629
+ // Check if rotation needed
630
+ if (this.rawLogSize >= this.config.maxFileSize) {
631
+ fs.closeSync(this.rawLogFd);
632
+ this.rotateLog(this.rawLogPath);
633
+ this.rawLogFd = fs.openSync(this.rawLogPath, 'a');
634
+ this.rawLogSize = 0;
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Write a JSON log entry
640
+ */
641
+ private writeJsonEntry(entry: JsonLogEntry): void {
642
+ if (this.jsonLogFd === null) return;
643
+
644
+ const line = JSON.stringify(entry) + '\n';
645
+ fs.writeSync(this.jsonLogFd, line);
646
+ }
647
+
648
+ /**
649
+ * Write stdout data
650
+ */
651
+ public writeStdout(data: Buffer | string): void {
652
+ const text = data.toString();
653
+
654
+ // Write raw log
655
+ if (this.config.keepRawLogs) {
656
+ this.writeToRawLog(text);
657
+ }
658
+
659
+ // Process through transform for clean log
660
+ if (this.cleanTransform) {
661
+ this.cleanTransform.write(data);
662
+ }
663
+
664
+ // Parse streaming JSON for readable log
665
+ this.parseStreamingData(text);
666
+
667
+ // Write JSON entry (for significant lines only)
668
+ if (this.config.writeJsonLog) {
669
+ const cleanText = stripAnsi(text).trim();
670
+ if (cleanText && !this.isNoiseLog(cleanText)) {
671
+ this.writeJsonEntry({
672
+ timestamp: new Date().toISOString(),
673
+ level: 'stdout',
674
+ lane: this.session.laneName,
675
+ task: this.session.taskName,
676
+ message: cleanText.substring(0, 1000), // Truncate very long lines
677
+ raw: this.config.keepRawLogs ? undefined : text.substring(0, 1000),
678
+ });
679
+ }
680
+ }
681
+ }
682
+
683
+ /**
684
+ * Parse streaming JSON data for readable log
685
+ */
686
+ private parseStreamingData(text: string): void {
687
+ if (!this.streamingParser) return;
688
+
689
+ // Buffer incomplete lines
690
+ this.lineBuffer += text;
691
+ const lines = this.lineBuffer.split('\n');
692
+
693
+ // Keep the last incomplete line in buffer
694
+ this.lineBuffer = lines.pop() || '';
695
+
696
+ // Parse complete lines
697
+ for (const line of lines) {
698
+ const trimmed = line.trim();
699
+ if (trimmed.startsWith('{')) {
700
+ this.streamingParser.parseLine(trimmed);
701
+ }
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Write stderr data
707
+ */
708
+ public writeStderr(data: Buffer | string): void {
709
+ const text = data.toString();
710
+
711
+ // Write raw log
712
+ if (this.config.keepRawLogs) {
713
+ this.writeToRawLog(text);
714
+ }
715
+
716
+ // Process through transform for clean log
717
+ if (this.cleanTransform) {
718
+ this.cleanTransform.write(data);
719
+ }
720
+
721
+ // Write JSON entry
722
+ if (this.config.writeJsonLog) {
723
+ const cleanText = stripAnsi(text).trim();
724
+ if (cleanText) {
725
+ this.writeJsonEntry({
726
+ timestamp: new Date().toISOString(),
727
+ level: 'stderr',
728
+ lane: this.session.laneName,
729
+ task: this.session.taskName,
730
+ message: cleanText.substring(0, 1000),
731
+ });
732
+ }
733
+ }
734
+ }
735
+
736
+ /**
737
+ * Write a custom log entry
738
+ */
739
+ public log(level: 'info' | 'error' | 'debug', message: string, metadata?: Record<string, any>): void {
740
+ const ts = formatTimestamp(this.config.timestampFormat, this.session.startTime);
741
+ const prefix = level.toUpperCase().padEnd(5);
742
+
743
+ const line = `[${ts}] [${prefix}] ${message}\n`;
744
+ this.writeToCleanLog(line);
745
+
746
+ if (this.config.keepRawLogs) {
747
+ this.writeToRawLog(line);
748
+ }
749
+
750
+ if (this.config.writeJsonLog) {
751
+ this.writeJsonEntry({
752
+ timestamp: new Date().toISOString(),
753
+ level,
754
+ lane: this.session.laneName,
755
+ task: this.session.taskName,
756
+ message,
757
+ metadata,
758
+ });
759
+ }
760
+ }
761
+
762
+ /**
763
+ * Add a section marker
764
+ */
765
+ public section(title: string): void {
766
+ const divider = '═'.repeat(78);
767
+ const line = `\n${divider}\n ${title}\n${divider}\n`;
768
+
769
+ this.writeToCleanLog(line);
770
+ if (this.config.keepRawLogs) {
771
+ this.writeToRawLog(line);
772
+ }
773
+ }
774
+
775
+ /**
776
+ * Update task context
777
+ */
778
+ public setTask(taskName: string, model?: string): void {
779
+ this.session.taskName = taskName;
780
+ if (model) {
781
+ this.session.model = model;
782
+ }
783
+
784
+ this.section(`Task: ${taskName}${model ? ` (Model: ${model})` : ''}`);
785
+
786
+ if (this.config.writeJsonLog) {
787
+ this.writeJsonEntry({
788
+ timestamp: new Date().toISOString(),
789
+ level: 'info',
790
+ source: 'system',
791
+ lane: this.session.laneName,
792
+ task: taskName,
793
+ message: `Task started: ${taskName}`,
794
+ metadata: { model },
795
+ });
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Check if a log line is noise (progress bars, spinners, etc.)
801
+ */
802
+ private isNoiseLog(text: string): boolean {
803
+ // Skip empty or whitespace-only
804
+ if (!text.trim()) return true;
805
+
806
+ // Skip common progress/spinner patterns
807
+ const noisePatterns = [
808
+ /^[\s│├└─┌┐┘┴┬┤├]+$/, // Box drawing only
809
+ /^[.\s]+$/, // Dots only
810
+ /^[=>\s-]+$/, // Progress bar characters
811
+ /^\d+%$/, // Percentage only
812
+ /^⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏/, // Spinner characters
813
+ ];
814
+
815
+ return noisePatterns.some(p => p.test(text));
816
+ }
817
+
818
+ /**
819
+ * Get paths to all log files
820
+ */
821
+ public getLogPaths(): { clean: string; raw?: string; json?: string; readable: string } {
822
+ return {
823
+ clean: this.cleanLogPath,
824
+ raw: this.config.keepRawLogs ? this.rawLogPath : undefined,
825
+ json: this.config.writeJsonLog ? this.jsonLogPath : undefined,
826
+ readable: this.readableLogPath,
827
+ };
828
+ }
829
+
830
+ /**
831
+ * Create file descriptors for process stdio redirection
832
+ */
833
+ public getFileDescriptors(): { stdout: number; stderr: number } {
834
+ // For process spawning, use the raw log fd if available, otherwise clean
835
+ const fd = this.rawLogFd !== null ? this.rawLogFd : this.cleanLogFd!;
836
+ return { stdout: fd, stderr: fd };
837
+ }
838
+
839
+ /**
840
+ * Close all log files
841
+ */
842
+ public close(): void {
843
+ // Flush transform stream
844
+ if (this.cleanTransform) {
845
+ this.cleanTransform.end();
846
+ }
847
+
848
+ // Flush streaming parser
849
+ if (this.streamingParser) {
850
+ // Parse any remaining buffered data
851
+ if (this.lineBuffer.trim()) {
852
+ this.streamingParser.parseLine(this.lineBuffer);
853
+ }
854
+ this.streamingParser.flush();
855
+ }
856
+
857
+ // Write session end marker
858
+ const endMarker = `
859
+ ╔══════════════════════════════════════════════════════════════════════════════╗
860
+ ║ Session Ended: ${new Date().toISOString().padEnd(60)}║
861
+ ║ Duration: ${this.formatDuration(Date.now() - this.session.startTime).padEnd(65)}║
862
+ ╚══════════════════════════════════════════════════════════════════════════════╝
863
+
864
+ `;
865
+
866
+ if (this.cleanLogFd !== null) {
867
+ fs.writeSync(this.cleanLogFd, endMarker);
868
+ fs.closeSync(this.cleanLogFd);
869
+ this.cleanLogFd = null;
870
+ }
871
+
872
+ if (this.rawLogFd !== null) {
873
+ fs.writeSync(this.rawLogFd, endMarker);
874
+ fs.closeSync(this.rawLogFd);
875
+ this.rawLogFd = null;
876
+ }
877
+
878
+ if (this.jsonLogFd !== null) {
879
+ this.writeJsonEntry({
880
+ timestamp: new Date().toISOString(),
881
+ level: 'session',
882
+ source: 'system',
883
+ lane: this.session.laneName,
884
+ message: 'Session ended',
885
+ metadata: {
886
+ sessionId: this.session.id,
887
+ duration: Date.now() - this.session.startTime,
888
+ },
889
+ });
890
+ fs.closeSync(this.jsonLogFd);
891
+ this.jsonLogFd = null;
892
+ }
893
+
894
+ // Close readable log
895
+ if (this.readableLogFd !== null) {
896
+ fs.writeSync(this.readableLogFd, endMarker);
897
+ fs.closeSync(this.readableLogFd);
898
+ this.readableLogFd = null;
899
+ }
900
+ }
901
+
902
+ /**
903
+ * Format duration for display
904
+ */
905
+ private formatDuration(ms: number): string {
906
+ const seconds = Math.floor((ms / 1000) % 60);
907
+ const minutes = Math.floor((ms / (1000 * 60)) % 60);
908
+ const hours = Math.floor(ms / (1000 * 60 * 60));
909
+
910
+ if (hours > 0) {
911
+ return `${hours}h ${minutes}m ${seconds}s`;
912
+ } else if (minutes > 0) {
913
+ return `${minutes}m ${seconds}s`;
914
+ }
915
+ return `${seconds}s`;
916
+ }
917
+ }
918
+
919
+ /**
920
+ * Create a log manager for a lane
921
+ */
922
+ export function createLogManager(
923
+ laneRunDir: string,
924
+ laneName: string,
925
+ config?: Partial<EnhancedLogConfig>
926
+ ): EnhancedLogManager {
927
+ const session: LogSession = {
928
+ id: `${laneName}-${Date.now().toString(36)}`,
929
+ laneName,
930
+ startTime: Date.now(),
931
+ };
932
+
933
+ return new EnhancedLogManager(laneRunDir, session, config);
934
+ }
935
+
936
+ /**
937
+ * Read and parse JSON log file
938
+ */
939
+ export function readJsonLog(logPath: string): JsonLogEntry[] {
940
+ if (!fs.existsSync(logPath)) {
941
+ return [];
942
+ }
943
+
944
+ try {
945
+ const content = fs.readFileSync(logPath, 'utf8');
946
+ return content
947
+ .split('\n')
948
+ .filter(line => line.trim())
949
+ .map(line => {
950
+ try {
951
+ return JSON.parse(line) as JsonLogEntry;
952
+ } catch {
953
+ return null;
954
+ }
955
+ })
956
+ .filter((entry): entry is JsonLogEntry => entry !== null);
957
+ } catch {
958
+ return [];
959
+ }
960
+ }
961
+
962
+ /**
963
+ * Export logs to various formats
964
+ */
965
+ export function exportLogs(
966
+ laneRunDir: string,
967
+ format: 'text' | 'json' | 'markdown' | 'html',
968
+ outputPath?: string
969
+ ): string {
970
+ const cleanLogPath = path.join(laneRunDir, 'terminal.log');
971
+ const jsonLogPath = path.join(laneRunDir, 'terminal.jsonl');
972
+
973
+ let output = '';
974
+
975
+ switch (format) {
976
+ case 'text':
977
+ if (fs.existsSync(cleanLogPath)) {
978
+ output = fs.readFileSync(cleanLogPath, 'utf8');
979
+ }
980
+ break;
981
+
982
+ case 'json':
983
+ const entries = readJsonLog(jsonLogPath);
984
+ output = JSON.stringify(entries, null, 2);
985
+ break;
986
+
987
+ case 'markdown':
988
+ output = exportToMarkdown(jsonLogPath, cleanLogPath);
989
+ break;
990
+
991
+ case 'html':
992
+ output = exportToHtml(jsonLogPath, cleanLogPath);
993
+ break;
994
+ }
995
+
996
+ if (outputPath) {
997
+ fs.writeFileSync(outputPath, output, 'utf8');
998
+ }
999
+
1000
+ return output;
1001
+ }
1002
+
1003
+ /**
1004
+ * Export logs to Markdown format
1005
+ */
1006
+ function exportToMarkdown(jsonLogPath: string, cleanLogPath: string): string {
1007
+ const entries = readJsonLog(jsonLogPath);
1008
+
1009
+ let md = '# CursorFlow Session Log\n\n';
1010
+
1011
+ // Find session info
1012
+ const sessionStart = entries.find(e => e.level === 'session' && e.message === 'Session started');
1013
+ if (sessionStart?.metadata) {
1014
+ md += '## Session Info\n\n';
1015
+ md += `- **Session ID**: ${sessionStart.metadata.sessionId}\n`;
1016
+ md += `- **Lane**: ${sessionStart.lane}\n`;
1017
+ md += `- **Model**: ${sessionStart.metadata.model || 'default'}\n`;
1018
+ md += `- **Started**: ${sessionStart.timestamp}\n\n`;
1019
+ }
1020
+
1021
+ md += '## Log Entries\n\n';
1022
+
1023
+ // Group by task
1024
+ const byTask = new Map<string, JsonLogEntry[]>();
1025
+ for (const entry of entries) {
1026
+ const task = entry.task || '(no task)';
1027
+ if (!byTask.has(task)) {
1028
+ byTask.set(task, []);
1029
+ }
1030
+ byTask.get(task)!.push(entry);
1031
+ }
1032
+
1033
+ for (const [task, taskEntries] of byTask) {
1034
+ md += `### Task: ${task}\n\n`;
1035
+ md += '```\n';
1036
+ for (const entry of taskEntries) {
1037
+ if (entry.level !== 'session') {
1038
+ md += `[${entry.timestamp}] [${entry.level.toUpperCase()}] ${entry.message}\n`;
1039
+ }
1040
+ }
1041
+ md += '```\n\n';
1042
+ }
1043
+
1044
+ return md;
1045
+ }
1046
+
1047
+ /**
1048
+ * Export logs to HTML format
1049
+ */
1050
+ function exportToHtml(jsonLogPath: string, cleanLogPath: string): string {
1051
+ const entries = readJsonLog(jsonLogPath);
1052
+
1053
+ let html = `<!DOCTYPE html>
1054
+ <html>
1055
+ <head>
1056
+ <title>CursorFlow Session Log</title>
1057
+ <style>
1058
+ body { font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; margin: 20px; background: #1e1e1e; color: #d4d4d4; }
1059
+ h1, h2 { color: #569cd6; }
1060
+ .entry { padding: 4px 8px; margin: 2px 0; border-radius: 4px; }
1061
+ .entry.stdout { background: #252526; }
1062
+ .entry.stderr { background: #3c1f1f; color: #f48771; }
1063
+ .entry.info { background: #1e3a5f; color: #9cdcfe; }
1064
+ .entry.error { background: #5f1e1e; color: #f48771; }
1065
+ .entry.session { background: #1e4620; color: #6a9955; }
1066
+ .timestamp { color: #808080; font-size: 0.9em; }
1067
+ .level { font-weight: bold; text-transform: uppercase; }
1068
+ .task { color: #dcdcaa; }
1069
+ pre { white-space: pre-wrap; word-wrap: break-word; }
1070
+ </style>
1071
+ </head>
1072
+ <body>
1073
+ <h1>CursorFlow Session Log</h1>
1074
+ `;
1075
+
1076
+ for (const entry of entries) {
1077
+ html += ` <div class="entry ${entry.level}">
1078
+ <span class="timestamp">${entry.timestamp}</span>
1079
+ <span class="level">[${entry.level}]</span>
1080
+ ${entry.task ? `<span class="task">[${entry.task}]</span>` : ''}
1081
+ <pre>${escapeHtml(entry.message)}</pre>
1082
+ </div>\n`;
1083
+ }
1084
+
1085
+ html += '</body></html>';
1086
+ return html;
1087
+ }
1088
+
1089
+ function escapeHtml(text: string): string {
1090
+ return text
1091
+ .replace(/&/g, '&amp;')
1092
+ .replace(/</g, '&lt;')
1093
+ .replace(/>/g, '&gt;')
1094
+ .replace(/"/g, '&quot;')
1095
+ .replace(/'/g, '&#039;');
1096
+ }
1097
+