ralphblaster-agent 0.1.1

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +294 -0
  3. package/bin/agent-dashboard.sh +168 -0
  4. package/bin/monitor-agent.sh +264 -0
  5. package/bin/ralphblaster.js +247 -0
  6. package/package.json +64 -0
  7. package/postinstall-colored.js +66 -0
  8. package/src/api-client.js +764 -0
  9. package/src/claude-plugin/.claude-plugin/plugin.json +9 -0
  10. package/src/claude-plugin/README.md +42 -0
  11. package/src/claude-plugin/skills/ralph/SKILL.md +259 -0
  12. package/src/commands/add-project.js +257 -0
  13. package/src/commands/init.js +79 -0
  14. package/src/config-file-manager.js +84 -0
  15. package/src/config.js +66 -0
  16. package/src/error-window.js +86 -0
  17. package/src/executor/claude-runner.js +716 -0
  18. package/src/executor/error-handler.js +65 -0
  19. package/src/executor/git-helper.js +196 -0
  20. package/src/executor/index.js +296 -0
  21. package/src/executor/job-handlers/clarifying-questions.js +213 -0
  22. package/src/executor/job-handlers/code-execution.js +145 -0
  23. package/src/executor/job-handlers/prd-generation.js +259 -0
  24. package/src/executor/path-helper.js +74 -0
  25. package/src/executor/prompt-validator.js +51 -0
  26. package/src/executor.js +4 -0
  27. package/src/index.js +342 -0
  28. package/src/logger.js +193 -0
  29. package/src/logging/README.md +93 -0
  30. package/src/logging/config.js +179 -0
  31. package/src/logging/destinations/README.md +290 -0
  32. package/src/logging/destinations/api-destination-unbatched.js +118 -0
  33. package/src/logging/destinations/api-destination.js +40 -0
  34. package/src/logging/destinations/base-destination.js +85 -0
  35. package/src/logging/destinations/batched-destination.js +198 -0
  36. package/src/logging/destinations/console-destination.js +172 -0
  37. package/src/logging/destinations/file-destination.js +208 -0
  38. package/src/logging/destinations/index.js +29 -0
  39. package/src/logging/destinations/progress-batch-destination-unbatched.js +92 -0
  40. package/src/logging/destinations/progress-batch-destination.js +41 -0
  41. package/src/logging/formatter.js +288 -0
  42. package/src/logging/log-manager.js +426 -0
  43. package/src/progress-throttle.js +101 -0
  44. package/src/system-monitor.js +64 -0
  45. package/src/utils/format.js +16 -0
  46. package/src/utils/log-file-helper.js +265 -0
  47. package/src/utils/progress-parser.js +250 -0
  48. package/src/worktree-manager.js +255 -0
@@ -0,0 +1,716 @@
1
+ const { spawn } = require('child_process');
2
+ const path = require('path');
3
+ const logger = require('../logger');
4
+ const { formatDuration } = require('../utils/format');
5
+
6
+ // Timeout constants
7
+ const TIMEOUTS = {
8
+ CLAUDE_EXECUTION_MS: 7200000, // 2 hours (fallback/default only)
9
+ DEFAULT_TIMEOUT_MINUTES: 60, // Default timeout if not specified in job
10
+ SAFETY_MARGIN_MINUTES: 1 // Agent terminates 1 min before Rails timeout
11
+ };
12
+
13
+ /**
14
+ * Calculate Claude execution timeout from job configuration
15
+ * Agent terminates 1 minute before Rails timeout to ensure clean state
16
+ * @param {Object} job - Job object from Rails API
17
+ * @returns {number} Timeout in milliseconds
18
+ */
19
+ function getClaudeTimeout(job) {
20
+ const timeoutMinutes = job.timeout_minutes || TIMEOUTS.DEFAULT_TIMEOUT_MINUTES;
21
+ const safetyMarginMinutes = TIMEOUTS.SAFETY_MARGIN_MINUTES;
22
+ const effectiveMinutes = Math.max(timeoutMinutes - safetyMarginMinutes, 5); // Min 5 minutes
23
+ return effectiveMinutes * 60 * 1000; // Convert to milliseconds
24
+ }
25
+
26
+ /**
27
+ * ClaudeRunner - Handles Claude CLI execution
28
+ *
29
+ * This class encapsulates all Claude CLI interaction including:
30
+ * - Spawning Claude processes with proper security
31
+ * - Streaming raw terminal output
32
+ * - Handling timeouts and errors
33
+ */
34
+ class ClaudeRunner {
35
+ constructor(errorHandler, gitHelper) {
36
+ this.errorHandler = errorHandler;
37
+ this.gitHelper = gitHelper;
38
+ this.currentProcess = null;
39
+ this.currentJobId = null;
40
+ this.apiClient = null;
41
+ this.capturedStderr = '';
42
+ // Debug mode enabled by default, disable with CLAUDE_STREAM_DEBUG=false
43
+ this.debugStream = process.env.CLAUDE_STREAM_DEBUG !== 'false';
44
+ }
45
+
46
+ /**
47
+ * Set the current job ID for event emission
48
+ * @param {number} jobId - Job identifier
49
+ */
50
+ setJobId(jobId) {
51
+ this.currentJobId = jobId;
52
+ }
53
+
54
+ /**
55
+ * Set the API client for progress updates
56
+ * @param {Object} apiClient - API client instance
57
+ */
58
+ setApiClient(apiClient) {
59
+ this.apiClient = apiClient;
60
+ }
61
+
62
+ /**
63
+ * Reset captured stderr
64
+ */
65
+ resetCapturedStderr() {
66
+ this.capturedStderr = '';
67
+ }
68
+
69
+ /**
70
+ * Format stream-json event for progress display
71
+ * @param {Object} event - Stream JSON event
72
+ * @returns {string|null} Formatted text or null if not displayable
73
+ */
74
+ formatStreamEvent(event) {
75
+ // Debug mode: log only interesting stream events, not every delta
76
+ if (this.debugStream) {
77
+ const interestingTypes = ['tool_use', 'tool_result', 'error', 'thinking'];
78
+ if (interestingTypes.includes(event.type)) {
79
+ const eventPreview = JSON.stringify(event).slice(0, 500);
80
+ logger.debug(`[STREAM] ${event.type} | ${eventPreview}`);
81
+ }
82
+ }
83
+
84
+ switch (event.type) {
85
+ case 'content_block_delta':
86
+ // Only show Claude's actual text output, not every tiny delta
87
+ return event.delta?.text || null;
88
+
89
+ case 'tool_use':
90
+ case 'tool_result':
91
+ // Tool usage is shown via assistant event content blocks
92
+ return null;
93
+
94
+ case 'thinking':
95
+ // Thinking content is verbose, skip for cleaner output
96
+ return null;
97
+
98
+ case 'error':
99
+ return `\n❌ Error: ${event.error?.message || 'Unknown error'}\n`;
100
+
101
+ case 'assistant': {
102
+ // Extract both text and tool usage for terminal display
103
+ const content = event.message?.content || [];
104
+ const parts = [];
105
+
106
+ for (const block of content) {
107
+ if (block.type === 'text' && block.text) {
108
+ // Include substantial text (not just whitespace)
109
+ const trimmed = block.text.trim();
110
+ if (trimmed.length > 0) {
111
+ parts.push(block.text);
112
+ }
113
+ } else if (block.type === 'tool_use') {
114
+ // Show tool usage in terminal for visibility
115
+ const toolName = block.name;
116
+ const input = block.input || {};
117
+
118
+ switch (toolName) {
119
+ case 'Read':
120
+ parts.push(`\n📖 Reading: ${input.file_path || 'file'}\n`);
121
+ break;
122
+ case 'Edit':
123
+ parts.push(`\n✏️ Editing: ${input.file_path || 'file'}\n`);
124
+ break;
125
+ case 'Write':
126
+ parts.push(`\n📝 Creating: ${input.file_path || 'file'}\n`);
127
+ break;
128
+ case 'Bash':
129
+ const cmd = input.command?.slice(0, 60) || 'command';
130
+ parts.push(`\n💻 Running: ${cmd}${input.command?.length > 60 ? '...' : ''}\n`);
131
+ break;
132
+ case 'Grep':
133
+ case 'Glob':
134
+ parts.push(`\n🔍 Searching: ${input.pattern || input.glob || 'files'}\n`);
135
+ break;
136
+ case 'Task':
137
+ parts.push(`\n🔄 Subtask: ${input.description?.slice(0, 60) || 'working...'}\n`);
138
+ break;
139
+ default:
140
+ parts.push(`\n🔧 ${toolName}\n`);
141
+ }
142
+ }
143
+ }
144
+ return parts.length > 0 ? parts.join('') : null;
145
+ }
146
+
147
+ case 'user':
148
+ // Skip user messages (tool results) - not useful in progress stream
149
+ return null;
150
+
151
+ case 'text':
152
+ return event.text || null;
153
+
154
+ case 'result':
155
+ // Skip result events - not useful for live progress
156
+ return null;
157
+
158
+ case 'system':
159
+ // Skip system events (init, hooks) - not useful for display
160
+ return null;
161
+
162
+ default:
163
+ if (this.debugStream) {
164
+ logger.debug(`Unhandled stream event type: ${event.type}`);
165
+ }
166
+ return null;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Send structured status events for Claude tool usage
172
+ * @param {Object} event - Stream JSON event
173
+ * @param {number} jobId - Job ID for API calls
174
+ */
175
+ async sendToolStatusEvent(event, jobId) {
176
+ if (!this.apiClient) return;
177
+
178
+ try {
179
+ if (event.type === 'assistant') {
180
+ const content = event.message?.content || [];
181
+ for (const block of content) {
182
+ if (block.type === 'tool_use') {
183
+ const toolName = block.name;
184
+ const input = block.input || {};
185
+
186
+ // Map tool names to valid Rails event types
187
+ let eventType;
188
+ let message;
189
+ const metadata = {};
190
+
191
+ switch (toolName) {
192
+ case 'Read':
193
+ eventType = 'read_file';
194
+ message = `📖 Reading: ${input.file_path?.split('/').pop() || 'file'}`;
195
+ metadata.file = input.file_path;
196
+ break;
197
+ case 'Edit':
198
+ eventType = 'edit_file';
199
+ message = `✏️ Editing: ${input.file_path?.split('/').pop() || 'file'}`;
200
+ metadata.file = input.file_path;
201
+ break;
202
+ case 'Write':
203
+ eventType = 'write_file';
204
+ message = `📝 Creating: ${input.file_path?.split('/').pop() || 'file'}`;
205
+ metadata.file = input.file_path;
206
+ break;
207
+ case 'Bash':
208
+ eventType = 'bash_command';
209
+ const cmd = input.command?.slice(0, 50) || 'command';
210
+ message = `💻 Running: ${cmd}${input.command?.length > 50 ? '...' : ''}`;
211
+ metadata.command = input.command?.slice(0, 200);
212
+ break;
213
+ case 'Grep':
214
+ case 'Glob':
215
+ eventType = 'search';
216
+ message = `🔍 Searching: ${input.pattern || input.glob || 'files'}`;
217
+ metadata.pattern = input.pattern || input.glob;
218
+ break;
219
+ case 'Task':
220
+ eventType = 'progress_update';
221
+ message = `🔄 Subtask: ${input.description?.slice(0, 50) || 'working...'}`;
222
+ metadata.subagent = input.subagent_type;
223
+ break;
224
+ default:
225
+ eventType = 'progress_update';
226
+ message = `🔧 Using: ${toolName}`;
227
+ metadata.tool = toolName;
228
+ }
229
+
230
+ await this.apiClient.sendStatusEvent(jobId, eventType, message, metadata);
231
+ }
232
+ // Phase 1.4: REMOVED duplicate text block status events
233
+ // Text blocks are already captured in progress chunks, so this was redundant
234
+ // Expected impact: Eliminate 5-10 duplicate events per job
235
+ }
236
+ }
237
+ } catch (err) {
238
+ logger.debug(`Failed to send tool status event: ${err.message}`);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Get sanitized environment variables for Claude execution
244
+ * @returns {Object} Sanitized environment object
245
+ */
246
+ getSanitizedEnv() {
247
+ const safeEnv = {};
248
+
249
+ // Explicitly allowed environment variables
250
+ const allowedVars = [
251
+ 'PATH',
252
+ 'HOME',
253
+ 'USER',
254
+ 'LANG',
255
+ 'LC_ALL',
256
+ 'TERM',
257
+ 'TMPDIR',
258
+ 'SHELL',
259
+ 'NODE_ENV' // Add NODE_ENV for Claude
260
+ ];
261
+
262
+ // Explicitly blocked patterns (even if they match allowed vars)
263
+ const blockedPatterns = [
264
+ /^RALPH_API_TOKEN$/i,
265
+ /^RALPHBLASTER_API_TOKEN$/i,
266
+ /^.*_TOKEN$/i,
267
+ /^.*_SECRET$/i,
268
+ /^.*_KEY$/i,
269
+ /^.*_PASSWORD$/i,
270
+ /^AWS_/i,
271
+ /^AZURE_/i,
272
+ /^GCP_/i,
273
+ /^GOOGLE_/i
274
+ ];
275
+
276
+ for (const key of allowedVars) {
277
+ if (process.env[key]) {
278
+ // Double-check not in blocklist
279
+ const isBlocked = blockedPatterns.some(pattern => pattern.test(key));
280
+ if (!isBlocked) {
281
+ safeEnv[key] = process.env[key];
282
+ }
283
+ }
284
+ }
285
+
286
+ // Don't log HOME to avoid exposing username in logs
287
+ const safeToLog = Object.keys(safeEnv).filter(k => k !== 'HOME');
288
+ logger.debug(`Sanitized environment: ${safeToLog.join(', ')}`);
289
+ return safeEnv;
290
+ }
291
+
292
+ /**
293
+ * Run Claude CLI with the given prompt
294
+ * @param {string} prompt - Prompt text
295
+ * @param {string} cwd - Working directory
296
+ * @param {Function} onProgress - Progress callback
297
+ * @param {number} timeout - Timeout in milliseconds (calculated from job.timeout_minutes)
298
+ * @param {Object} job - Job object (optional, for logging timeout details)
299
+ * @returns {Promise<string>} Command output
300
+ */
301
+ runClaude(prompt, cwd, onProgress, timeout = TIMEOUTS.CLAUDE_EXECUTION_MS, job = null) {
302
+ return new Promise((resolve, reject) => {
303
+ const timeoutFormatted = formatDuration(timeout);
304
+ const logData = {
305
+ timeout: timeoutFormatted,
306
+ workingDirectory: cwd,
307
+ promptLength: prompt.length
308
+ };
309
+
310
+ // Log timeout configuration if job provided
311
+ if (job && job.timeout_minutes) {
312
+ logData.jobTimeoutMinutes = job.timeout_minutes;
313
+ logData.calculatedTimeoutMinutes = Math.floor(timeout / 60000);
314
+ logger.info(`Using job-specific timeout: ${job.timeout_minutes} minutes (agent will terminate at ${Math.floor(timeout / 60000)} minutes)`);
315
+ }
316
+
317
+ logger.info(`Starting Claude CLI execution`, logData);
318
+
319
+ // Use stdin to pass prompt - avoids shell injection
320
+ // Use stream-json format for structured progress events
321
+ logger.info('Spawning Claude CLI process with stream-json output');
322
+ const claude = spawn('claude', ['-p', '--output-format', 'stream-json', '--include-partial-messages', '--permission-mode', 'acceptEdits', '--verbose'], {
323
+ cwd: cwd,
324
+ shell: false,
325
+ env: this.getSanitizedEnv()
326
+ });
327
+
328
+ logger.info('Claude CLI process spawned, writing prompt to stdin...');
329
+
330
+ // Set timeout
331
+ const timer = setTimeout(() => {
332
+ logger.error(`Claude CLI timed out after ${timeout}ms (${timeoutFormatted})`);
333
+ claude.kill('SIGTERM');
334
+ reject(new Error(`Claude CLI execution timed out after ${timeout}ms`));
335
+ }, timeout);
336
+
337
+ // Track process for shutdown cleanup
338
+ this.currentProcess = claude;
339
+
340
+ // Send prompt via stdin (safe from injection)
341
+ try {
342
+ claude.stdin.write(prompt);
343
+ claude.stdin.end();
344
+ logger.info('Prompt successfully written to Claude CLI stdin');
345
+ } catch (err) {
346
+ logger.error('Failed to write prompt to Claude CLI stdin', { error: err.message });
347
+ clearTimeout(timer);
348
+ reject(new Error(`Failed to write prompt to Claude: ${err.message}`));
349
+ return;
350
+ }
351
+
352
+ let stdout = '';
353
+ let stderr = '';
354
+ let buffer = ''; // Buffer for incomplete JSON lines
355
+
356
+ claude.stdout.on('data', (data) => {
357
+ const chunk = data.toString();
358
+ buffer += chunk;
359
+
360
+ // Process complete JSON lines
361
+ const lines = buffer.split('\n');
362
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
363
+
364
+ for (const line of lines) {
365
+ if (!line.trim()) continue;
366
+
367
+ try {
368
+ const event = JSON.parse(line);
369
+
370
+ // Accumulate final output from content blocks or result events
371
+ if (event.type === 'content_block_delta' && event.delta?.text) {
372
+ stdout += event.delta.text;
373
+ } else if (event.type === 'result' && event.result) {
374
+ // Claude stream-json puts final output in result event
375
+ stdout = event.result;
376
+ }
377
+
378
+ // Send progress updates for all event types
379
+ const progressText = this.formatStreamEvent(event);
380
+ if (progressText) {
381
+ // Write to stdout for terminal visibility
382
+ process.stdout.write(progressText);
383
+
384
+ // Send to progress callback if provided
385
+ if (onProgress) {
386
+ onProgress(progressText);
387
+ }
388
+ }
389
+ } catch (err) {
390
+ // If JSON parse fails, treat as raw output
391
+ logger.debug(`Non-JSON stdout line: ${line.slice(0, 100)}`);
392
+ stdout += line + '\n';
393
+
394
+ // Write to stdout for terminal visibility
395
+ process.stdout.write(line + '\n');
396
+
397
+ if (onProgress) {
398
+ onProgress(line + '\n');
399
+ }
400
+ }
401
+ }
402
+ });
403
+
404
+ claude.stderr.on('data', (data) => {
405
+ const stderrChunk = data.toString();
406
+ stderr += stderrChunk;
407
+ // Save to instance variable for log file
408
+ this.capturedStderr = (this.capturedStderr || '') + stderrChunk;
409
+
410
+ // Log stderr but don't treat as JSON
411
+ process.stderr.write(stderrChunk);
412
+
413
+ // Send stderr to progress callback (Claude's interactive output comes on stderr)
414
+ if (onProgress) {
415
+ onProgress(stderrChunk);
416
+ }
417
+ });
418
+
419
+ claude.on('close', (code) => {
420
+ clearTimeout(timer); // Clear timeout
421
+ this.currentProcess = null; // Clear process reference
422
+
423
+ logger.info(`Claude CLI process exited`, {
424
+ exitCode: code,
425
+ stdoutLength: stdout.length,
426
+ stderrLength: stderr.length
427
+ });
428
+
429
+ if (code === 0) {
430
+ logger.info('Claude CLI execution completed successfully');
431
+ resolve(stdout);
432
+ } else {
433
+ logger.error(`Claude CLI exited with non-zero code ${code}`);
434
+ logger.error('Last 1000 chars of stderr:', stderr.slice(-1000));
435
+
436
+ const baseError = new Error(`Claude CLI failed with exit code ${code}: ${stderr}`);
437
+ const errorInfo = this.errorHandler.categorizeError(baseError, stderr, code);
438
+
439
+ logger.error('Error categorization:', {
440
+ category: errorInfo.category,
441
+ userMessage: errorInfo.userMessage
442
+ });
443
+
444
+ // Attach categorization to error object
445
+ const enrichedError = new Error(errorInfo.userMessage);
446
+ enrichedError.category = errorInfo.category;
447
+ enrichedError.technicalDetails = errorInfo.technicalDetails;
448
+ enrichedError.partialOutput = stdout; // Include any partial output
449
+
450
+ reject(enrichedError);
451
+ }
452
+ });
453
+
454
+ claude.on('error', (error) => {
455
+ clearTimeout(timer); // Clear timeout
456
+ this.currentProcess = null; // Clear process reference
457
+ logger.error('Failed to spawn Claude CLI process', {
458
+ error: error.message,
459
+ code: error.code,
460
+ errno: error.errno,
461
+ syscall: error.syscall
462
+ });
463
+
464
+ const errorInfo = this.errorHandler.categorizeError(error, stderr, null);
465
+
466
+ logger.error('Spawn error categorization:', {
467
+ category: errorInfo.category,
468
+ userMessage: errorInfo.userMessage
469
+ });
470
+
471
+ // Attach categorization to error object
472
+ const enrichedError = new Error(errorInfo.userMessage);
473
+ enrichedError.category = errorInfo.category;
474
+ enrichedError.technicalDetails = errorInfo.technicalDetails;
475
+ enrichedError.partialOutput = stdout; // Include any partial output
476
+
477
+ reject(enrichedError);
478
+ });
479
+ });
480
+ }
481
+
482
+ /**
483
+ * Run Claude Code directly in worktree with raw prompt
484
+ * @param {string} worktreePath - Path to worktree
485
+ * @param {string} prompt - Raw PRD/task description (from job.prompt)
486
+ * @param {Object} job - Job object for progress updates
487
+ * @param {Function} onProgress - Progress callback
488
+ * @returns {Promise<{output: string, branchName: string, duration: number}>}
489
+ */
490
+ async runClaudeDirectly(worktreePath, prompt, job, onProgress) {
491
+ const startTime = Date.now();
492
+ const timeout = getClaudeTimeout(job);
493
+
494
+ const logData = {
495
+ timeout: formatDuration(timeout),
496
+ workingDirectory: worktreePath,
497
+ promptLength: prompt.length,
498
+ jobTimeoutMinutes: job.timeout_minutes || TIMEOUTS.DEFAULT_TIMEOUT_MINUTES,
499
+ calculatedTimeoutMinutes: Math.floor(timeout / 60000)
500
+ };
501
+
502
+ logger.info(`Running Claude Code in worktree: ${worktreePath}`, logData);
503
+ logger.info(`Using job-specific timeout: ${job.timeout_minutes || TIMEOUTS.DEFAULT_TIMEOUT_MINUTES} minutes (agent will terminate at ${Math.floor(timeout / 60000)} minutes)`);
504
+
505
+ // Send to API/UI
506
+ if (this.apiClient && this.apiClient.sendProgress) {
507
+ this.apiClient.sendProgress(job.id, `Running Claude Code in worktree: ${path.basename(worktreePath)}\n`)
508
+ .catch(err => logger.warn(`Failed to send progress to API: ${err.message}`));
509
+ }
510
+
511
+ logger.event('claude_started', {
512
+ component: 'executor',
513
+ operation: 'claude_direct',
514
+ worktreePath
515
+ });
516
+
517
+ // Spawn Claude with stream-json output for structured progress events
518
+ logger.info('Spawning Claude CLI process with stream-json output');
519
+
520
+ // Send to API/UI
521
+ if (this.apiClient && this.apiClient.sendProgress) {
522
+ this.apiClient.sendProgress(job.id, 'Spawning Claude CLI process with stream-json output\n')
523
+ .catch(err => logger.warn(`Failed to send progress to API: ${err.message}`));
524
+ }
525
+
526
+ const claudeProcess = spawn('claude', ['-p', '--output-format', 'stream-json', '--include-partial-messages', '--permission-mode', 'acceptEdits', '--verbose'], {
527
+ cwd: worktreePath,
528
+ shell: false,
529
+ env: this.getSanitizedEnv()
530
+ });
531
+
532
+ // Set timeout (same as current runClaude)
533
+ const timer = setTimeout(() => {
534
+ logger.error(`Claude CLI timed out after ${timeout}ms`);
535
+ claudeProcess.kill('SIGTERM');
536
+ }, timeout);
537
+
538
+ // Track process for shutdown cleanup
539
+ this.currentProcess = claudeProcess;
540
+
541
+ // Send prompt via stdin (safe from injection)
542
+ try {
543
+ claudeProcess.stdin.write(prompt);
544
+ claudeProcess.stdin.end();
545
+ logger.info('Prompt successfully written to Claude CLI stdin');
546
+
547
+ // Send to API/UI
548
+ if (this.apiClient && this.apiClient.sendProgress) {
549
+ this.apiClient.sendProgress(job.id, 'Prompt successfully written to Claude CLI stdin\n')
550
+ .catch(err => logger.warn(`Failed to send progress to API: ${err.message}`));
551
+ }
552
+ } catch (err) {
553
+ logger.error('Failed to write prompt to Claude CLI stdin', { error: err.message });
554
+ clearTimeout(timer);
555
+ throw new Error(`Failed to write prompt to Claude: ${err.message}`);
556
+ }
557
+
558
+ let output = '';
559
+ let errorOutput = '';
560
+ let buffer = ''; // Buffer for incomplete JSON lines
561
+
562
+ return new Promise((resolve, reject) => {
563
+ // Capture stdout and parse stream-json events
564
+ claudeProcess.stdout.on('data', async (data) => {
565
+ const chunk = data.toString();
566
+ buffer += chunk;
567
+
568
+ // Process complete JSON lines
569
+ const lines = buffer.split('\n');
570
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
571
+
572
+ for (const line of lines) {
573
+ if (!line.trim()) continue;
574
+
575
+ try {
576
+ const event = JSON.parse(line);
577
+
578
+ // Accumulate final output from content blocks or result events
579
+ if (event.type === 'content_block_delta' && event.delta?.text) {
580
+ output += event.delta.text;
581
+ } else if (event.type === 'result' && event.result) {
582
+ // Claude stream-json puts final output in result event
583
+ output = event.result;
584
+ }
585
+
586
+ // Send structured status events for tool usage
587
+ await this.sendToolStatusEvent(event, job.id);
588
+
589
+ // Send progress updates for all event types
590
+ const progressText = this.formatStreamEvent(event);
591
+ if (progressText) {
592
+ // Write to stdout for terminal visibility
593
+ process.stdout.write(progressText);
594
+
595
+ // Call onProgress callback if provided (it will handle API streaming with throttling)
596
+ if (onProgress) {
597
+ onProgress(progressText);
598
+ } else if (this.apiClient) {
599
+ // Only send directly to API if no onProgress callback (prevents duplicate updates)
600
+ try {
601
+ await this.apiClient.sendProgress(job.id, progressText);
602
+ } catch (err) {
603
+ logger.warn(`Failed to send progress to API: ${err.message}`);
604
+ }
605
+ }
606
+ }
607
+ } catch (err) {
608
+ // If JSON parse fails, treat as raw output
609
+ logger.debug(`Non-JSON stdout line: ${line.slice(0, 100)}`);
610
+ output += line + '\n';
611
+
612
+ // Write to stdout for terminal visibility
613
+ process.stdout.write(line + '\n');
614
+
615
+ if (onProgress) {
616
+ onProgress(line + '\n');
617
+ } else if (this.apiClient) {
618
+ try {
619
+ await this.apiClient.sendProgress(job.id, line + '\n');
620
+ } catch (err) {
621
+ logger.warn(`Failed to send progress to API: ${err.message}`);
622
+ }
623
+ }
624
+ }
625
+ }
626
+ });
627
+
628
+ // Capture stderr
629
+ claudeProcess.stderr.on('data', async (data) => {
630
+ const chunk = data.toString();
631
+ errorOutput += chunk;
632
+ // Save to instance variable for log file
633
+ this.capturedStderr = (this.capturedStderr || '') + chunk;
634
+
635
+ // Debug: confirm we're receiving stderr
636
+ logger.debug(`Received Claude stderr chunk (${chunk.length} bytes)`);
637
+
638
+ // Log stderr but don't treat as JSON
639
+ process.stderr.write(chunk);
640
+
641
+ // Call onProgress callback if provided (it will handle API streaming with throttling)
642
+ if (onProgress) {
643
+ onProgress(chunk);
644
+ } else if (this.apiClient) {
645
+ // Only send directly to API if no onProgress callback (prevents duplicate updates)
646
+ try {
647
+ await this.apiClient.sendProgress(job.id, chunk);
648
+ } catch (err) {
649
+ logger.warn(`Failed to send stderr progress to API: ${err.message}`);
650
+ }
651
+ }
652
+ });
653
+
654
+ // Wait for completion
655
+ claudeProcess.on('close', async (code) => {
656
+ clearTimeout(timer);
657
+ this.currentProcess = null;
658
+ const duration = Date.now() - startTime;
659
+
660
+ if (code === 0) {
661
+ logger.info(`Claude completed successfully in ${formatDuration(duration)}`, {
662
+ outputLength: output.length,
663
+ outputPreview: output.slice(0, 200)
664
+ });
665
+
666
+ // Warn if output is empty - likely stream parsing issue
667
+ if (!output || output.length === 0) {
668
+ logger.warn('Claude output is empty! Stream-json events may not have been parsed correctly.');
669
+ }
670
+
671
+ // Get branch name from worktree
672
+ const branchName = await this.gitHelper.getCurrentBranch(worktreePath);
673
+
674
+ resolve({
675
+ output: output,
676
+ branchName,
677
+ duration
678
+ });
679
+ } else {
680
+ logger.error(`Claude failed with code ${code}`);
681
+
682
+ // Use existing error categorization
683
+ const baseError = new Error(`Claude CLI failed with exit code ${code}: ${errorOutput}`);
684
+ const errorInfo = this.errorHandler.categorizeError(baseError, errorOutput, code);
685
+
686
+ // Create enriched error (same pattern as existing runClaude)
687
+ const enrichedError = new Error(errorInfo.userMessage);
688
+ enrichedError.category = errorInfo.category;
689
+ enrichedError.technicalDetails = errorInfo.technicalDetails;
690
+ enrichedError.partialOutput = output;
691
+
692
+ reject(enrichedError);
693
+ }
694
+ });
695
+
696
+ claudeProcess.on('error', (err) => {
697
+ clearTimeout(timer);
698
+ this.currentProcess = null;
699
+ logger.error(`Failed to spawn Claude CLI: ${err.message}`);
700
+
701
+ // Use existing error categorization
702
+ const errorInfo = this.errorHandler.categorizeError(err, errorOutput, null);
703
+
704
+ const enrichedError = new Error(errorInfo.userMessage);
705
+ enrichedError.category = errorInfo.category;
706
+ enrichedError.technicalDetails = errorInfo.technicalDetails;
707
+ enrichedError.partialOutput = output;
708
+
709
+ reject(enrichedError);
710
+ });
711
+ });
712
+ }
713
+
714
+ }
715
+
716
+ module.exports = ClaudeRunner;