@litmers/cursorflow-orchestrator 0.1.15 → 0.1.20

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 (90) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +26 -7
  3. package/commands/cursorflow-run.md +2 -0
  4. package/commands/cursorflow-triggers.md +250 -0
  5. package/dist/cli/clean.js +8 -7
  6. package/dist/cli/clean.js.map +1 -1
  7. package/dist/cli/index.js +5 -1
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/cli/init.js +20 -14
  10. package/dist/cli/init.js.map +1 -1
  11. package/dist/cli/logs.js +64 -47
  12. package/dist/cli/logs.js.map +1 -1
  13. package/dist/cli/monitor.js +27 -17
  14. package/dist/cli/monitor.js.map +1 -1
  15. package/dist/cli/prepare.js +73 -33
  16. package/dist/cli/prepare.js.map +1 -1
  17. package/dist/cli/resume.js +193 -40
  18. package/dist/cli/resume.js.map +1 -1
  19. package/dist/cli/run.js +3 -2
  20. package/dist/cli/run.js.map +1 -1
  21. package/dist/cli/signal.js +7 -7
  22. package/dist/cli/signal.js.map +1 -1
  23. package/dist/core/orchestrator.d.ts +2 -1
  24. package/dist/core/orchestrator.js +54 -93
  25. package/dist/core/orchestrator.js.map +1 -1
  26. package/dist/core/reviewer.d.ts +6 -4
  27. package/dist/core/reviewer.js +7 -5
  28. package/dist/core/reviewer.js.map +1 -1
  29. package/dist/core/runner.d.ts +8 -0
  30. package/dist/core/runner.js +219 -32
  31. package/dist/core/runner.js.map +1 -1
  32. package/dist/utils/config.js +20 -10
  33. package/dist/utils/config.js.map +1 -1
  34. package/dist/utils/doctor.js +35 -7
  35. package/dist/utils/doctor.js.map +1 -1
  36. package/dist/utils/enhanced-logger.d.ts +2 -2
  37. package/dist/utils/enhanced-logger.js +114 -43
  38. package/dist/utils/enhanced-logger.js.map +1 -1
  39. package/dist/utils/git.js +163 -10
  40. package/dist/utils/git.js.map +1 -1
  41. package/dist/utils/log-formatter.d.ts +16 -0
  42. package/dist/utils/log-formatter.js +194 -0
  43. package/dist/utils/log-formatter.js.map +1 -0
  44. package/dist/utils/path.d.ts +19 -0
  45. package/dist/utils/path.js +77 -0
  46. package/dist/utils/path.js.map +1 -0
  47. package/dist/utils/repro-thinking-logs.d.ts +1 -0
  48. package/dist/utils/repro-thinking-logs.js +80 -0
  49. package/dist/utils/repro-thinking-logs.js.map +1 -0
  50. package/dist/utils/state.d.ts +4 -1
  51. package/dist/utils/state.js +11 -8
  52. package/dist/utils/state.js.map +1 -1
  53. package/dist/utils/template.d.ts +14 -0
  54. package/dist/utils/template.js +122 -0
  55. package/dist/utils/template.js.map +1 -0
  56. package/dist/utils/types.d.ts +13 -0
  57. package/dist/utils/webhook.js +3 -0
  58. package/dist/utils/webhook.js.map +1 -1
  59. package/package.json +4 -2
  60. package/scripts/ai-security-check.js +3 -0
  61. package/scripts/local-security-gate.sh +9 -1
  62. package/scripts/verify-and-fix.sh +37 -0
  63. package/src/cli/clean.ts +8 -7
  64. package/src/cli/index.ts +5 -1
  65. package/src/cli/init.ts +19 -15
  66. package/src/cli/logs.ts +67 -47
  67. package/src/cli/monitor.ts +28 -18
  68. package/src/cli/prepare.ts +75 -35
  69. package/src/cli/resume.ts +810 -626
  70. package/src/cli/run.ts +3 -2
  71. package/src/cli/signal.ts +7 -6
  72. package/src/core/orchestrator.ts +68 -93
  73. package/src/core/reviewer.ts +14 -9
  74. package/src/core/runner.ts +229 -33
  75. package/src/utils/config.ts +19 -11
  76. package/src/utils/doctor.ts +38 -7
  77. package/src/utils/enhanced-logger.ts +117 -49
  78. package/src/utils/git.ts +145 -11
  79. package/src/utils/log-formatter.ts +162 -0
  80. package/src/utils/path.ts +45 -0
  81. package/src/utils/repro-thinking-logs.ts +54 -0
  82. package/src/utils/state.ts +16 -8
  83. package/src/utils/template.ts +92 -0
  84. package/src/utils/types.ts +13 -0
  85. package/src/utils/webhook.ts +3 -0
  86. package/templates/basic.json +21 -0
  87. package/scripts/simple-logging-test.sh +0 -97
  88. package/scripts/test-real-cursor-lifecycle.sh +0 -289
  89. package/scripts/test-real-logging.sh +0 -289
  90. package/scripts/test-streaming-multi-task.sh +0 -247
@@ -13,8 +13,9 @@
13
13
 
14
14
  import * as fs from 'fs';
15
15
  import * as path from 'path';
16
- import { PassThrough, Transform, TransformCallback } from 'stream';
16
+ import { Transform, TransformCallback } from 'stream';
17
17
  import { EnhancedLogConfig } from './types';
18
+ import { safeJoin } from './path';
18
19
 
19
20
  // Re-export for backwards compatibility
20
21
  export { EnhancedLogConfig } from './types';
@@ -163,6 +164,23 @@ export class StreamingMessageParser {
163
164
  },
164
165
  });
165
166
  break;
167
+
168
+ case 'thinking':
169
+ // Thinking message (Claude 3.7+ etc.)
170
+ if (json.subtype === 'delta' && json.text) {
171
+ // Check if this is a new message or continuation
172
+ if (this.currentRole !== 'thinking') {
173
+ // Flush previous message if any
174
+ this.flush();
175
+ this.currentRole = 'thinking';
176
+ this.messageStartTime = json.timestamp_ms || Date.now();
177
+ }
178
+ this.currentMessage += json.text;
179
+ } else if (json.subtype === 'completed') {
180
+ // Thinking completed - flush immediately
181
+ this.flush();
182
+ }
183
+ break;
166
184
  }
167
185
  }
168
186
 
@@ -191,7 +209,7 @@ export class StreamingMessageParser {
191
209
  }
192
210
 
193
211
  export interface ParsedMessage {
194
- type: 'system' | 'user' | 'assistant' | 'tool' | 'tool_result' | 'result';
212
+ type: 'system' | 'user' | 'assistant' | 'tool' | 'tool_result' | 'result' | 'thinking';
195
213
  role: string;
196
214
  content: string;
197
215
  timestamp: number;
@@ -396,10 +414,10 @@ export class EnhancedLogManager {
396
414
  fs.mkdirSync(logDir, { recursive: true });
397
415
 
398
416
  // Set up log file paths
399
- this.cleanLogPath = path.join(logDir, 'terminal.log');
400
- this.rawLogPath = path.join(logDir, 'terminal-raw.log');
401
- this.jsonLogPath = path.join(logDir, 'terminal.jsonl');
402
- this.readableLogPath = path.join(logDir, 'terminal-readable.log');
417
+ this.cleanLogPath = safeJoin(logDir, 'terminal.log');
418
+ this.rawLogPath = safeJoin(logDir, 'terminal-raw.log');
419
+ this.jsonLogPath = safeJoin(logDir, 'terminal.jsonl');
420
+ this.readableLogPath = safeJoin(logDir, 'terminal-readable.log');
403
421
 
404
422
  // Initialize log files
405
423
  this.initLogFiles();
@@ -505,6 +523,21 @@ export class EnhancedLogManager {
505
523
  formatted = `[${ts}] 📄 RESL: ${msg.metadata?.toolName || 'Tool'}${toolResultLines}\n`;
506
524
  break;
507
525
 
526
+ case 'thinking':
527
+ // Format thinking block
528
+ const thinkLabel = `[ 🤔 THINKING ] `;
529
+ const thinkWidth = 80;
530
+ const thinkTop = `┌─${thinkLabel}${'─'.repeat(Math.max(0, thinkWidth - thinkLabel.length - 2))}`;
531
+ const thinkBottom = `└─${'─'.repeat(thinkWidth - 2)}`;
532
+
533
+ const thinkLines = msg.content.trim().split('\n');
534
+ formatted = `[${ts}] ${thinkTop}\n`;
535
+ for (const line of thinkLines) {
536
+ formatted += `[${ts}] │ ${line}\n`;
537
+ }
538
+ formatted += `[${ts}] ${thinkBottom}\n`;
539
+ break;
540
+
508
541
  default:
509
542
  formatted = `[${ts}] ${msg.content}\n`;
510
543
  }
@@ -588,8 +621,8 @@ export class EnhancedLogManager {
588
621
 
589
622
  // Shift existing rotated files
590
623
  for (let i = this.config.maxFiles - 1; i >= 1; i--) {
591
- const oldPath = path.join(dir, `${base}.${i}${ext}`);
592
- const newPath = path.join(dir, `${base}.${i + 1}${ext}`);
624
+ const oldPath = safeJoin(dir, `${base}.${i}${ext}`);
625
+ const newPath = safeJoin(dir, `${base}.${i + 1}${ext}`);
593
626
 
594
627
  if (fs.existsSync(oldPath)) {
595
628
  if (i === this.config.maxFiles - 1) {
@@ -601,7 +634,7 @@ export class EnhancedLogManager {
601
634
  }
602
635
 
603
636
  // Rotate current to .1
604
- const rotatedPath = path.join(dir, `${base}.1${ext}`);
637
+ const rotatedPath = safeJoin(dir, `${base}.1${ext}`);
605
638
  fs.renameSync(logPath, rotatedPath);
606
639
  }
607
640
 
@@ -669,20 +702,73 @@ export class EnhancedLogManager {
669
702
  this.cleanTransform.write(data);
670
703
  }
671
704
 
672
- // Parse streaming JSON for readable log (handles boxes, messages, tool calls)
673
- this.parseStreamingData(text);
705
+ // Process lines for readable log and JSON entries
706
+ this.lineBuffer += text;
707
+ const lines = this.lineBuffer.split('\n');
708
+ this.lineBuffer = lines.pop() || '';
674
709
 
675
- // Also include significant info/status lines in readable log (compact)
676
- if (this.readableLogFd !== null) {
677
- const lines = text.split('\n');
678
- for (const line of lines) {
679
- const cleanLine = stripAnsi(line).trim();
710
+ for (const line of lines) {
711
+ const cleanLine = stripAnsi(line).trim();
712
+ if (!cleanLine) continue;
713
+
714
+ // Handle streaming JSON messages (for boxes, etc. in readable log)
715
+ if (cleanLine.startsWith('{')) {
716
+ if (this.streamingParser) {
717
+ this.streamingParser.parseLine(cleanLine);
718
+ }
719
+
720
+ // Special handling for terminal.jsonl entries for AI messages
721
+ if (this.config.writeJsonLog) {
722
+ try {
723
+ const json = JSON.parse(cleanLine);
724
+ let displayMsg = cleanLine;
725
+ let metadata = { ...json };
726
+
727
+ // Extract cleaner text for significant AI message types
728
+ if ((json.type === 'thinking' || json.type === 'thought') && (json.text || json.thought)) {
729
+ displayMsg = json.text || json.thought;
730
+ // Clean up any double newlines at the end of deltas
731
+ displayMsg = displayMsg.replace(/\n+$/, '\n');
732
+ } else if (json.type === 'assistant' && json.message?.content) {
733
+ displayMsg = json.message.content
734
+ .filter((c: any) => c.type === 'text')
735
+ .map((c: any) => c.text)
736
+ .join('');
737
+ } else if (json.type === 'user' && json.message?.content) {
738
+ displayMsg = json.message.content
739
+ .filter((c: any) => c.type === 'text')
740
+ .map((c: any) => c.text)
741
+ .join('');
742
+ } else if (json.type === 'tool_call' && json.subtype === 'started') {
743
+ const toolName = Object.keys(json.tool_call)[0] || 'unknown';
744
+ const args = json.tool_call[toolName]?.args || {};
745
+ displayMsg = `🔧 CALL: ${toolName}(${JSON.stringify(args)})`;
746
+ } else if (json.type === 'tool_call' && json.subtype === 'completed') {
747
+ const toolName = Object.keys(json.tool_call)[0] || 'unknown';
748
+ displayMsg = `📄 RESL: ${toolName}`;
749
+ } else if (json.type === 'result') {
750
+ displayMsg = json.result || 'Task completed';
751
+ }
752
+
753
+ this.writeJsonEntry({
754
+ timestamp: new Date().toISOString(),
755
+ level: 'stdout',
756
+ lane: this.session.laneName,
757
+ task: this.session.taskName,
758
+ message: displayMsg.substring(0, 2000), // Larger limit for AI text
759
+ metadata,
760
+ });
761
+ continue; // Already logged this JSON line
762
+ } catch {
763
+ // Not valid JSON or error, fall through to regular logging
764
+ }
765
+ }
766
+ }
767
+
768
+ // Also include significant info/status lines in readable log (compact)
769
+ if (this.readableLogFd !== null) {
680
770
  // Look for log lines: [ISO_DATE] [LEVEL] ...
681
- if (cleanLine &&
682
- !cleanLine.startsWith('{') &&
683
- !this.isNoiseLog(cleanLine) &&
684
- /\[\d{4}-\d{2}-\d{2}T/.test(cleanLine)) {
685
-
771
+ if (!this.isNoiseLog(cleanLine) && /\[\d{4}-\d{2}-\d{2}T/.test(cleanLine)) {
686
772
  try {
687
773
  // Check if it has a level marker
688
774
  if (/\[(INFO|WARN|ERROR|SUCCESS|DEBUG)\]/.test(cleanLine)) {
@@ -698,44 +784,26 @@ export class EnhancedLogManager {
698
784
  } catch {}
699
785
  }
700
786
  }
701
- }
702
-
703
- // Write JSON entry (for significant lines only)
704
- if (this.config.writeJsonLog) {
705
- const cleanText = stripAnsi(text).trim();
706
- if (cleanText && !this.isNoiseLog(cleanText)) {
787
+
788
+ // Write regular non-JSON lines to terminal.jsonl
789
+ if (this.config.writeJsonLog && !this.isNoiseLog(cleanLine)) {
707
790
  this.writeJsonEntry({
708
791
  timestamp: new Date().toISOString(),
709
792
  level: 'stdout',
710
793
  lane: this.session.laneName,
711
794
  task: this.session.taskName,
712
- message: cleanText.substring(0, 1000), // Truncate very long lines
713
- raw: this.config.keepRawLogs ? undefined : text.substring(0, 1000),
795
+ message: cleanLine.substring(0, 1000),
796
+ raw: this.config.keepRawLogs ? undefined : line.substring(0, 1000),
714
797
  });
715
798
  }
716
799
  }
717
800
  }
718
801
 
719
802
  /**
720
- * Parse streaming JSON data for readable log
803
+ * Parse streaming JSON data for readable log - legacy, integrated into writeStdout
721
804
  */
722
805
  private parseStreamingData(text: string): void {
723
- if (!this.streamingParser) return;
724
-
725
- // Buffer incomplete lines
726
- this.lineBuffer += text;
727
- const lines = this.lineBuffer.split('\n');
728
-
729
- // Keep the last incomplete line in buffer
730
- this.lineBuffer = lines.pop() || '';
731
-
732
- // Parse complete lines
733
- for (const line of lines) {
734
- const trimmed = line.trim();
735
- if (trimmed.startsWith('{')) {
736
- this.streamingParser.parseLine(trimmed);
737
- }
738
- }
806
+ // Legacy method, no longer used but kept for internal references if any
739
807
  }
740
808
 
741
809
  /**
@@ -873,7 +941,7 @@ export class EnhancedLogManager {
873
941
 
874
942
  // Skip common progress/spinner patterns
875
943
  const noisePatterns = [
876
- /^[\s│├└─┌┐┘┴┬┤├]+$/, // Box drawing only
944
+ /^[\s│├└─┌┐┘┴┬┤]+$/, // Box drawing only (removed duplicate ├)
877
945
  /^[.\s]+$/, // Dots only
878
946
  /^[=>\s-]+$/, // Progress bar characters
879
947
  /^\d+%$/, // Percentage only
@@ -1036,8 +1104,8 @@ export function exportLogs(
1036
1104
  format: 'text' | 'json' | 'markdown' | 'html',
1037
1105
  outputPath?: string
1038
1106
  ): string {
1039
- const cleanLogPath = path.join(laneRunDir, 'terminal.log');
1040
- const jsonLogPath = path.join(laneRunDir, 'terminal.jsonl');
1107
+ const cleanLogPath = safeJoin(laneRunDir, 'terminal.log');
1108
+ const jsonLogPath = safeJoin(laneRunDir, 'terminal.jsonl');
1041
1109
 
1042
1110
  let output = '';
1043
1111
 
package/src/utils/git.ts CHANGED
@@ -3,6 +3,77 @@
3
3
  */
4
4
 
5
5
  import { execSync, spawnSync } from 'child_process';
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import { safeJoin } from './path';
9
+
10
+ /**
11
+ * Acquire a file-based lock for Git operations
12
+ */
13
+ function acquireLock(lockName: string, cwd?: string): string | null {
14
+ const repoRoot = cwd || getRepoRoot();
15
+ const lockDir = safeJoin(repoRoot, '_cursorflow', 'locks');
16
+ if (!fs.existsSync(lockDir)) {
17
+ fs.mkdirSync(lockDir, { recursive: true });
18
+ }
19
+
20
+ const lockFile = safeJoin(lockDir, `${lockName}.lock`);
21
+
22
+ try {
23
+ // wx flag ensures atomic creation
24
+ fs.writeFileSync(lockFile, String(process.pid), { flag: 'wx' });
25
+ return lockFile;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Release a file-based lock
33
+ */
34
+ function releaseLock(lockFile: string | null): void {
35
+ if (lockFile && fs.existsSync(lockFile)) {
36
+ try {
37
+ fs.unlinkSync(lockFile);
38
+ } catch {
39
+ // Ignore
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Run Git command with locking
46
+ */
47
+ async function runGitWithLock<T>(
48
+ lockName: string,
49
+ fn: () => T,
50
+ options: { cwd?: string; maxRetries?: number; retryDelay?: number } = {}
51
+ ): Promise<T> {
52
+ const maxRetries = options.maxRetries ?? 10;
53
+ const retryDelay = options.retryDelay ?? 500;
54
+
55
+ let retries = 0;
56
+ let lockFile = null;
57
+
58
+ while (retries < maxRetries) {
59
+ lockFile = acquireLock(lockName, options.cwd);
60
+ if (lockFile) break;
61
+
62
+ retries++;
63
+ const delay = Math.floor(Math.random() * retryDelay) + retryDelay / 2;
64
+ await new Promise(resolve => setTimeout(resolve, delay));
65
+ }
66
+
67
+ if (!lockFile) {
68
+ throw new Error(`Failed to acquire lock: ${lockName}`);
69
+ }
70
+
71
+ try {
72
+ return fn();
73
+ } finally {
74
+ releaseLock(lockFile);
75
+ }
76
+ }
6
77
 
7
78
  export interface GitRunOptions {
8
79
  cwd?: string;
@@ -36,6 +107,25 @@ export interface CommitInfo {
36
107
  subject: string;
37
108
  }
38
109
 
110
+ /**
111
+ * Filter out noisy git stderr messages
112
+ */
113
+ function filterGitStderr(stderr: string): string {
114
+ if (!stderr) return '';
115
+
116
+ const lines = stderr.split('\n');
117
+ const filtered = lines.filter(line => {
118
+ // GitHub noise
119
+ if (line.includes('remote: Create a pull request')) return false;
120
+ if (line.trim().startsWith('remote:') && line.includes('pull/new')) return false;
121
+ if (line.trim() === 'remote:') return false; // Empty remote lines
122
+
123
+ return true;
124
+ });
125
+
126
+ return filtered.join('\n');
127
+ }
128
+
39
129
  /**
40
130
  * Run git command and return output
41
131
  */
@@ -43,12 +133,21 @@ export function runGit(args: string[], options: GitRunOptions = {}): string {
43
133
  const { cwd, silent = false } = options;
44
134
 
45
135
  try {
136
+ const stdioMode = silent ? 'pipe' : ['inherit', 'inherit', 'pipe'];
137
+
46
138
  const result = spawnSync('git', args, {
47
139
  cwd: cwd || process.cwd(),
48
140
  encoding: 'utf8',
49
- stdio: silent ? 'pipe' : 'inherit',
141
+ stdio: stdioMode as any,
50
142
  });
51
143
 
144
+ if (!silent && result.stderr) {
145
+ const filteredStderr = filterGitStderr(result.stderr);
146
+ if (filteredStderr) {
147
+ process.stderr.write(filteredStderr);
148
+ }
149
+ }
150
+
52
151
  if (result.status !== 0 && !silent) {
53
152
  throw new Error(`Git command failed: git ${args.join(' ')}\n${result.stderr || ''}`);
54
153
  }
@@ -120,18 +219,53 @@ export function worktreeExists(worktreePath: string, cwd?: string): boolean {
120
219
  export function createWorktree(worktreePath: string, branchName: string, options: { cwd?: string; baseBranch?: string } = {}): string {
121
220
  const { cwd, baseBranch = 'main' } = options;
122
221
 
123
- // Check if branch already exists
124
- const branchExists = runGitResult(['rev-parse', '--verify', branchName], { cwd }).success;
222
+ // Use a file-based lock to prevent race conditions during worktree creation
223
+ const lockDir = safeJoin(cwd || getRepoRoot(), '_cursorflow', 'locks');
224
+ if (!fs.existsSync(lockDir)) {
225
+ fs.mkdirSync(lockDir, { recursive: true });
226
+ }
227
+ const lockFile = safeJoin(lockDir, 'worktree.lock');
228
+
229
+ let retries = 20;
230
+ let acquired = false;
231
+
232
+ while (retries > 0 && !acquired) {
233
+ try {
234
+ fs.writeFileSync(lockFile, String(process.pid), { flag: 'wx' });
235
+ acquired = true;
236
+ } catch {
237
+ retries--;
238
+ const delay = Math.floor(Math.random() * 500) + 200;
239
+ // Use synchronous sleep to keep the function signature synchronous
240
+ const end = Date.now() + delay;
241
+ while (Date.now() < end) { /* wait */ }
242
+ }
243
+ }
125
244
 
126
- if (branchExists) {
127
- // Branch exists, checkout to worktree
128
- runGit(['worktree', 'add', worktreePath, branchName], { cwd });
129
- } else {
130
- // Create new branch from base
131
- runGit(['worktree', 'add', '-b', branchName, worktreePath, baseBranch], { cwd });
245
+ if (!acquired) {
246
+ throw new Error('Failed to acquire worktree lock after multiple retries');
132
247
  }
133
248
 
134
- return worktreePath;
249
+ try {
250
+ // Check if branch already exists
251
+ const branchExists = runGitResult(['rev-parse', '--verify', branchName], { cwd }).success;
252
+
253
+ if (branchExists) {
254
+ // Branch exists, checkout to worktree
255
+ runGit(['worktree', 'add', worktreePath, branchName], { cwd });
256
+ } else {
257
+ // Create new branch from base
258
+ runGit(['worktree', 'add', '-b', branchName, worktreePath, baseBranch], { cwd });
259
+ }
260
+
261
+ return worktreePath;
262
+ } finally {
263
+ try {
264
+ fs.unlinkSync(lockFile);
265
+ } catch {
266
+ // Ignore
267
+ }
268
+ }
135
269
  }
136
270
 
137
271
  /**
@@ -362,4 +496,4 @@ export function getLastOperationStats(cwd?: string): string {
362
496
  } catch (e) {
363
497
  return '';
364
498
  }
365
- }
499
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Utility for formatting log messages for console display
3
+ */
4
+
5
+ import * as logger from './logger';
6
+ import { ParsedMessage, stripAnsi } from './enhanced-logger';
7
+
8
+ /**
9
+ * Format a single parsed message into a human-readable string (compact or multi-line)
10
+ */
11
+ export function formatMessageForConsole(
12
+ msg: ParsedMessage,
13
+ options: {
14
+ includeTimestamp?: boolean;
15
+ laneLabel?: string;
16
+ compact?: boolean;
17
+ } = {}
18
+ ): string {
19
+ const { includeTimestamp = true, laneLabel = '', compact = false } = options;
20
+ const ts = includeTimestamp ? new Date(msg.timestamp).toLocaleTimeString('en-US', { hour12: false }) : '';
21
+ const tsPrefix = ts ? `${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ` : '';
22
+ const labelPrefix = laneLabel ? `${logger.COLORS.magenta}${laneLabel.padEnd(12)}${logger.COLORS.reset} ` : '';
23
+
24
+ let typePrefix = '';
25
+ let content = msg.content;
26
+
27
+ switch (msg.type) {
28
+ case 'user':
29
+ typePrefix = `${logger.COLORS.cyan}🧑 USER${logger.COLORS.reset}`;
30
+ if (compact) content = content.replace(/\n/g, ' ').substring(0, 100) + (content.length > 100 ? '...' : '');
31
+ break;
32
+ case 'assistant':
33
+ typePrefix = `${logger.COLORS.green}🤖 ASST${logger.COLORS.reset}`;
34
+ if (compact) content = content.replace(/\n/g, ' ').substring(0, 100) + (content.length > 100 ? '...' : '');
35
+ break;
36
+ case 'tool':
37
+ typePrefix = `${logger.COLORS.yellow}🔧 TOOL${logger.COLORS.reset}`;
38
+ const toolMatch = content.match(/\[Tool: ([^\]]+)\] (.*)/);
39
+ if (toolMatch) {
40
+ const [, name, args] = toolMatch;
41
+ try {
42
+ const parsedArgs = JSON.parse(args!);
43
+ let argStr = '';
44
+ if (name === 'read_file' && parsedArgs.target_file) {
45
+ argStr = parsedArgs.target_file;
46
+ } else if (name === 'run_terminal_cmd' && parsedArgs.command) {
47
+ argStr = parsedArgs.command;
48
+ } else if (name === 'write' && parsedArgs.file_path) {
49
+ argStr = parsedArgs.file_path;
50
+ } else if (name === 'search_replace' && parsedArgs.file_path) {
51
+ argStr = parsedArgs.file_path;
52
+ } else {
53
+ const keys = Object.keys(parsedArgs);
54
+ if (keys.length > 0) {
55
+ argStr = String(parsedArgs[keys[0]]).substring(0, 50);
56
+ }
57
+ }
58
+ content = `${logger.COLORS.bold}${name}${logger.COLORS.reset}(${argStr})`;
59
+ } catch {
60
+ content = `${logger.COLORS.bold}${name}${logger.COLORS.reset}: ${args}`;
61
+ }
62
+ }
63
+ break;
64
+ case 'tool_result':
65
+ typePrefix = `${logger.COLORS.gray}📄 RESL${logger.COLORS.reset}`;
66
+ const resMatch = content.match(/\[Tool Result: ([^\]]+)\]/);
67
+ content = resMatch ? `${resMatch[1]} OK` : 'result';
68
+ break;
69
+ case 'result':
70
+ typePrefix = `${logger.COLORS.green}✅ DONE${logger.COLORS.reset}`;
71
+ break;
72
+ case 'system':
73
+ typePrefix = `${logger.COLORS.gray}⚙️ SYS${logger.COLORS.reset}`;
74
+ break;
75
+ case 'thinking':
76
+ typePrefix = `${logger.COLORS.gray}🤔 THNK${logger.COLORS.reset}`;
77
+ if (compact) content = content.replace(/\n/g, ' ').substring(0, 100) + (content.length > 100 ? '...' : '');
78
+ break;
79
+ }
80
+
81
+ if (!typePrefix) return `${tsPrefix}${labelPrefix}${content}`;
82
+
83
+ if (compact) {
84
+ return `${tsPrefix}${labelPrefix}${typePrefix} ${content}`;
85
+ }
86
+
87
+ // Multi-line box format (as seen in orchestrator)
88
+ const lines = content.split('\n');
89
+ const fullPrefix = `${tsPrefix}${labelPrefix}`;
90
+ const header = `${typePrefix} ┌${'─'.repeat(60)}`;
91
+ let result = `${fullPrefix}${header}\n`;
92
+
93
+ const indent = ' '.repeat(stripAnsi(typePrefix).length);
94
+ for (const line of lines) {
95
+ result += `${fullPrefix}${indent} │ ${line}\n`;
96
+ }
97
+ result += `${fullPrefix}${indent} └${'─'.repeat(60)}`;
98
+
99
+ return result;
100
+ }
101
+
102
+ /**
103
+ * Detect and format a message that might be a raw JSON string from cursor-agent
104
+ */
105
+ export function formatPotentialJsonMessage(message: string): string {
106
+ const trimmed = message.trim();
107
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
108
+ return message;
109
+ }
110
+
111
+ try {
112
+ const json = JSON.parse(trimmed);
113
+ if (!json.type) return message;
114
+
115
+ // Convert JSON to a ParsedMessage-like structure for formatting
116
+ let content = trimmed;
117
+ let type = 'system';
118
+
119
+ if (json.type === 'thinking' && json.text) {
120
+ content = json.text;
121
+ type = 'thinking';
122
+ } else if (json.type === 'assistant' && json.message?.content) {
123
+ content = json.message.content
124
+ .filter((c: any) => c.type === 'text')
125
+ .map((c: any) => c.text)
126
+ .join('');
127
+ type = 'assistant';
128
+ } else if (json.type === 'user' && json.message?.content) {
129
+ content = json.message.content
130
+ .filter((c: any) => c.type === 'text')
131
+ .map((c: any) => c.text)
132
+ .join('');
133
+ type = 'user';
134
+ } else if (json.type === 'tool_call' && json.subtype === 'started') {
135
+ const toolName = Object.keys(json.tool_call)[0] || 'unknown';
136
+ const args = json.tool_call[toolName]?.args || {};
137
+ content = `[Tool: ${toolName}] ${JSON.stringify(args)}`;
138
+ type = 'tool';
139
+ } else if (json.type === 'tool_call' && json.subtype === 'completed') {
140
+ const toolName = Object.keys(json.tool_call)[0] || 'unknown';
141
+ content = `[Tool Result: ${toolName}]`;
142
+ type = 'tool_result';
143
+ } else if (json.type === 'result') {
144
+ content = json.result || 'Task completed';
145
+ type = 'result';
146
+ } else {
147
+ // Unknown type, return as is
148
+ return message;
149
+ }
150
+
151
+ return formatMessageForConsole({
152
+ type: type as any,
153
+ role: type,
154
+ content,
155
+ timestamp: json.timestamp_ms || Date.now()
156
+ }, { includeTimestamp: false, compact: true });
157
+
158
+ } catch {
159
+ return message;
160
+ }
161
+ }
162
+
@@ -0,0 +1,45 @@
1
+ import * as path from 'path';
2
+
3
+ /**
4
+ * Ensures that a path is safe and stays within a base directory.
5
+ * Prevents path traversal attacks.
6
+ */
7
+ export function isSafePath(baseDir: string, ...parts: string[]): boolean {
8
+ const joined = path.join(baseDir, ...parts); // nosemgrep
9
+ const resolvedBase = path.resolve(baseDir); // nosemgrep
10
+ const resolvedJoined = path.resolve(joined); // nosemgrep
11
+
12
+ return resolvedJoined.startsWith(resolvedBase);
13
+ }
14
+
15
+ /**
16
+ * Safely joins path parts and ensures the result is within the base directory.
17
+ * Throws an error if path traversal is detected.
18
+ *
19
+ * @param baseDir The base directory that the resulting path must be within
20
+ * @param parts Path parts to join
21
+ * @returns The joined path
22
+ * @throws Error if the resulting path is outside the base directory
23
+ */
24
+ export function safeJoin(baseDir: string, ...parts: string[]): string {
25
+ const joined = path.join(baseDir, ...parts); // nosemgrep
26
+ const resolvedBase = path.resolve(baseDir); // nosemgrep
27
+ const resolvedJoined = path.resolve(joined); // nosemgrep
28
+
29
+ if (!resolvedJoined.startsWith(resolvedBase)) {
30
+ throw new Error(`Potential path traversal detected: ${joined} is outside of ${baseDir}`);
31
+ }
32
+
33
+ return joined;
34
+ }
35
+
36
+ /**
37
+ * Normalizes a path and checks if it's absolute or relative to project root.
38
+ */
39
+ export function normalizePath(p: string, projectRoot: string): string {
40
+ if (path.isAbsolute(p)) {
41
+ return path.normalize(p);
42
+ }
43
+ return path.join(projectRoot, p); // nosemgrep
44
+ }
45
+