@link-assistant/hive-mind 0.39.0

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 (63) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +24 -0
  3. package/README.md +769 -0
  4. package/package.json +58 -0
  5. package/src/agent.lib.mjs +705 -0
  6. package/src/agent.prompts.lib.mjs +196 -0
  7. package/src/buildUserMention.lib.mjs +71 -0
  8. package/src/claude-limits.lib.mjs +389 -0
  9. package/src/claude.lib.mjs +1445 -0
  10. package/src/claude.prompts.lib.mjs +203 -0
  11. package/src/codex.lib.mjs +552 -0
  12. package/src/codex.prompts.lib.mjs +194 -0
  13. package/src/config.lib.mjs +207 -0
  14. package/src/contributing-guidelines.lib.mjs +268 -0
  15. package/src/exit-handler.lib.mjs +205 -0
  16. package/src/git.lib.mjs +145 -0
  17. package/src/github-issue-creator.lib.mjs +246 -0
  18. package/src/github-linking.lib.mjs +152 -0
  19. package/src/github.batch.lib.mjs +272 -0
  20. package/src/github.graphql.lib.mjs +258 -0
  21. package/src/github.lib.mjs +1479 -0
  22. package/src/hive.config.lib.mjs +254 -0
  23. package/src/hive.mjs +1500 -0
  24. package/src/instrument.mjs +191 -0
  25. package/src/interactive-mode.lib.mjs +1000 -0
  26. package/src/lenv-reader.lib.mjs +206 -0
  27. package/src/lib.mjs +490 -0
  28. package/src/lino.lib.mjs +176 -0
  29. package/src/local-ci-checks.lib.mjs +324 -0
  30. package/src/memory-check.mjs +419 -0
  31. package/src/model-mapping.lib.mjs +145 -0
  32. package/src/model-validation.lib.mjs +278 -0
  33. package/src/opencode.lib.mjs +479 -0
  34. package/src/opencode.prompts.lib.mjs +194 -0
  35. package/src/protect-branch.mjs +159 -0
  36. package/src/review.mjs +433 -0
  37. package/src/reviewers-hive.mjs +643 -0
  38. package/src/sentry.lib.mjs +284 -0
  39. package/src/solve.auto-continue.lib.mjs +568 -0
  40. package/src/solve.auto-pr.lib.mjs +1374 -0
  41. package/src/solve.branch-errors.lib.mjs +341 -0
  42. package/src/solve.branch.lib.mjs +230 -0
  43. package/src/solve.config.lib.mjs +342 -0
  44. package/src/solve.error-handlers.lib.mjs +256 -0
  45. package/src/solve.execution.lib.mjs +291 -0
  46. package/src/solve.feedback.lib.mjs +436 -0
  47. package/src/solve.mjs +1128 -0
  48. package/src/solve.preparation.lib.mjs +210 -0
  49. package/src/solve.repo-setup.lib.mjs +114 -0
  50. package/src/solve.repository.lib.mjs +961 -0
  51. package/src/solve.results.lib.mjs +558 -0
  52. package/src/solve.session.lib.mjs +135 -0
  53. package/src/solve.validation.lib.mjs +325 -0
  54. package/src/solve.watch.lib.mjs +572 -0
  55. package/src/start-screen.mjs +324 -0
  56. package/src/task.mjs +308 -0
  57. package/src/telegram-bot.mjs +1481 -0
  58. package/src/telegram-markdown.lib.mjs +64 -0
  59. package/src/usage-limit.lib.mjs +218 -0
  60. package/src/version.lib.mjs +41 -0
  61. package/src/youtrack/solve.youtrack.lib.mjs +116 -0
  62. package/src/youtrack/youtrack-sync.mjs +219 -0
  63. package/src/youtrack/youtrack.lib.mjs +425 -0
@@ -0,0 +1,1445 @@
1
+ #!/usr/bin/env node
2
+ // Claude CLI-related utility functions
3
+ // If not, fetch it (when running standalone)
4
+ if (typeof globalThis.use === 'undefined') {
5
+ globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
6
+ }
7
+ const { $ } = await use('command-stream');
8
+ const fs = (await use('fs')).promises;
9
+ const path = (await use('path')).default;
10
+ // Import log from general lib
11
+ import { log, cleanErrorMessage } from './lib.mjs';
12
+ import { reportError } from './sentry.lib.mjs';
13
+ import { timeouts, retryLimits } from './config.lib.mjs';
14
+ import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
15
+ import { createInteractiveHandler } from './interactive-mode.lib.mjs';
16
+ /**
17
+ * Format numbers with spaces as thousands separator (no commas)
18
+ * Per issue #667: Use spaces for thousands, . for decimals
19
+ * @param {number|null|undefined} num - Number to format
20
+ * @returns {string} Formatted number string
21
+ */
22
+ export const formatNumber = (num) => {
23
+ if (num === null || num === undefined) return 'N/A';
24
+ // Convert to string and split on decimal point
25
+ const parts = num.toString().split('.');
26
+ const integerPart = parts[0];
27
+ const decimalPart = parts[1];
28
+ // Add spaces every 3 digits from the right
29
+ const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
30
+ // Return with decimal part if it exists
31
+ return decimalPart !== undefined
32
+ ? `${formattedInteger}.${decimalPart}`
33
+ : formattedInteger;
34
+ };
35
+ // Available model configurations
36
+ export const availableModels = {
37
+ 'sonnet': 'claude-sonnet-4-5-20250929', // Sonnet 4.5
38
+ 'opus': 'claude-opus-4-5-20251101', // Opus 4.5
39
+ 'haiku': 'claude-haiku-4-5-20251001', // Haiku 4.5
40
+ 'haiku-3-5': 'claude-3-5-haiku-20241022', // Haiku 3.5
41
+ 'haiku-3': 'claude-3-haiku-20240307', // Haiku 3
42
+ };
43
+ // Model mapping to translate aliases to full model IDs
44
+ export const mapModelToId = (model) => {
45
+ return availableModels[model] || model;
46
+ };
47
+ // Function to validate Claude CLI connection with retry logic
48
+ export const validateClaudeConnection = async (model = 'haiku-3') => {
49
+ // Map model alias to full ID
50
+ const mappedModel = mapModelToId(model);
51
+ // Retry configuration for API overload errors
52
+ const maxRetries = 3;
53
+ const baseDelay = timeouts.retryBaseDelay;
54
+ let retryCount = 0;
55
+ const attemptValidation = async () => {
56
+ try {
57
+ if (retryCount === 0) {
58
+ await log('🔍 Validating Claude CLI connection...');
59
+ } else {
60
+ await log(`🔄 Retry attempt ${retryCount}/${maxRetries} for Claude CLI validation...`);
61
+ }
62
+ // First try a quick validation approach
63
+ try {
64
+ const versionResult = await $`timeout ${Math.floor(timeouts.claudeCli / 6000)} claude --version`;
65
+ if (versionResult.code === 0) {
66
+ const version = versionResult.stdout?.toString().trim();
67
+ if (retryCount === 0) {
68
+ await log(`📦 Claude CLI version: ${version}`);
69
+ }
70
+ }
71
+ } catch (versionError) {
72
+ // Version check failed, but we'll continue with the main validation
73
+ if (retryCount === 0) {
74
+ await log(`⚠️ Claude CLI version check failed (${versionError.code}), proceeding with connection test...`);
75
+ }
76
+ }
77
+ let result;
78
+ try {
79
+ // Primary validation: use printf piping with specified model
80
+ result = await $`printf hi | claude --model ${mappedModel} -p`;
81
+ } catch (pipeError) {
82
+ // If piping fails, fallback to the timeout approach as last resort
83
+ await log(`⚠️ Pipe validation failed (${pipeError.code}), trying timeout approach...`);
84
+ try {
85
+ result = await $`timeout ${Math.floor(timeouts.claudeCli / 1000)} claude --model ${mappedModel} -p hi`;
86
+ } catch (timeoutError) {
87
+ if (timeoutError.code === 124) {
88
+ await log(`❌ Claude CLI timed out after ${Math.floor(timeouts.claudeCli / 1000)} seconds`, { level: 'error' });
89
+ await log(' 💡 This may indicate Claude CLI is taking too long to respond', { level: 'error' });
90
+ await log(` 💡 Try running 'claude --model ${mappedModel} -p hi' manually to verify it works`, { level: 'error' });
91
+ return false;
92
+ }
93
+ // Re-throw if it's not a timeout error
94
+ throw timeoutError;
95
+ }
96
+ }
97
+
98
+ // Check for common error patterns
99
+ const stdout = result.stdout?.toString() || '';
100
+ const stderr = result.stderr?.toString() || '';
101
+ // Check for JSON errors in stdout or stderr
102
+ const checkForJsonError = (text) => {
103
+ try {
104
+ // Look for JSON error patterns
105
+ if (text.includes('"error"') && text.includes('"type"')) {
106
+ const jsonMatch = text.match(/\{.*"error".*\}/);
107
+ if (jsonMatch) {
108
+ const errorObj = JSON.parse(jsonMatch[0]);
109
+ return errorObj.error;
110
+ }
111
+ }
112
+ } catch (e) {
113
+ // Not valid JSON, continue with other checks
114
+ if (global.verboseMode) {
115
+ reportError(e, {
116
+ context: 'claude_json_error_parse',
117
+ level: 'debug'
118
+ });
119
+ }
120
+ }
121
+ return null;
122
+ };
123
+ const jsonError = checkForJsonError(stdout) || checkForJsonError(stderr);
124
+ // Check for API overload error pattern
125
+ const isOverloadError = (stdout.includes('API Error: 500') && stdout.includes('Overloaded')) ||
126
+ (stderr.includes('API Error: 500') && stderr.includes('Overloaded')) ||
127
+ (jsonError && jsonError.type === 'api_error' && jsonError.message === 'Overloaded');
128
+
129
+ // Handle overload errors with retry
130
+ if (isOverloadError) {
131
+ if (retryCount < maxRetries) {
132
+ const delay = baseDelay * Math.pow(2, retryCount);
133
+ await log(`⚠️ API overload error during validation. Retrying in ${delay / 1000} seconds...`, { level: 'warning' });
134
+ await new Promise(resolve => setTimeout(resolve, delay));
135
+ retryCount++;
136
+ return await attemptValidation();
137
+ } else {
138
+ await log(`❌ API overload error persisted after ${maxRetries} retries during validation`, { level: 'error' });
139
+ await log(' The API appears to be heavily loaded. Please try again later.', { level: 'error' });
140
+ return false;
141
+ }
142
+ }
143
+ // Use exitCode if code is undefined (Bun shell behavior)
144
+ const exitCode = result.code ?? result.exitCode ?? 0;
145
+ if (exitCode !== 0) {
146
+ // Command failed
147
+ if (jsonError) {
148
+ await log(`❌ Claude CLI authentication failed: ${jsonError.type} - ${jsonError.message}`, { level: 'error' });
149
+ } else {
150
+ await log(`❌ Claude CLI failed with exit code ${exitCode}`, { level: 'error' });
151
+ if (stderr) await log(` Error: ${stderr.trim()}`, { level: 'error' });
152
+ }
153
+ if (stderr.includes('Please run /login') || (jsonError && jsonError.type === 'forbidden')) {
154
+ await log(' 💡 Please run: claude login', { level: 'error' });
155
+ }
156
+ return false;
157
+ }
158
+ // Check for error patterns in successful response
159
+ if (jsonError) {
160
+ if (jsonError.type === 'api_error' && jsonError.message === 'Overloaded') {
161
+ if (retryCount < maxRetries) {
162
+ const delay = baseDelay * Math.pow(2, retryCount);
163
+ await log(`⚠️ API overload error in response. Retrying in ${delay / 1000} seconds...`, { level: 'warning' });
164
+ await new Promise(resolve => setTimeout(resolve, delay));
165
+ retryCount++;
166
+ return await attemptValidation();
167
+ } else {
168
+ await log(`❌ API overload error persisted after ${maxRetries} retries`, { level: 'error' });
169
+ return false;
170
+ }
171
+ }
172
+ await log(`❌ Claude CLI returned error: ${jsonError.type} - ${jsonError.message}`, { level: 'error' });
173
+ if (jsonError.type === 'forbidden') {
174
+ await log(' 💡 Please run: claude login', { level: 'error' });
175
+ }
176
+ return false;
177
+ }
178
+ // Success - Claude responded (LLM responses are probabilistic, so any response is good)
179
+ await log('✅ Claude CLI connection validated successfully');
180
+ return true;
181
+ } catch (error) {
182
+ const errorStr = error.message || error.toString();
183
+ if ((errorStr.includes('API Error: 500') && errorStr.includes('Overloaded')) ||
184
+ (errorStr.includes('api_error') && errorStr.includes('Overloaded'))) {
185
+ if (retryCount < maxRetries) {
186
+ const delay = baseDelay * Math.pow(2, retryCount);
187
+ await log(`⚠️ API overload error during validation. Retrying in ${delay / 1000} seconds...`, { level: 'warning' });
188
+ await new Promise(resolve => setTimeout(resolve, delay));
189
+ retryCount++;
190
+ return await attemptValidation();
191
+ } else {
192
+ await log(`❌ API overload error persisted after ${maxRetries} retries`, { level: 'error' });
193
+ return false;
194
+ }
195
+ }
196
+ await log(`❌ Failed to validate Claude CLI connection: ${error.message}`, { level: 'error' });
197
+ await log(' 💡 Make sure Claude CLI is installed and accessible', { level: 'error' });
198
+ return false;
199
+ }
200
+ }; // End of attemptValidation function
201
+ // Start the validation with retry logic
202
+ return await attemptValidation();
203
+ };
204
+ // Function to handle Claude runtime switching between Node.js and Bun
205
+ export const handleClaudeRuntimeSwitch = async (argv) => {
206
+ if (argv['force-claude-bun-run']) {
207
+ await log('\n🔧 Switching Claude runtime to bun...');
208
+ try {
209
+ try {
210
+ await $`which bun`;
211
+ await log(' ✅ Bun runtime found');
212
+ } catch (bunError) {
213
+ reportError(bunError, {
214
+ context: 'claude.lib.mjs - bun availability check',
215
+ level: 'error'
216
+ });
217
+ await log('❌ Bun runtime not found. Please install bun first: https://bun.sh/', { level: 'error' });
218
+ process.exit(1);
219
+ }
220
+
221
+ // Find Claude executable path
222
+ const claudePathResult = await $`which claude`;
223
+ const claudePath = claudePathResult.stdout.toString().trim();
224
+
225
+ if (!claudePath) {
226
+ await log('❌ Claude executable not found', { level: 'error' });
227
+ process.exit(1);
228
+ }
229
+
230
+ await log(` Claude path: ${claudePath}`);
231
+
232
+ try {
233
+ await fs.access(claudePath, fs.constants.W_OK);
234
+ } catch (accessError) {
235
+ reportError(accessError, {
236
+ context: 'claude.lib.mjs - Claude executable write permission check (bun)',
237
+ level: 'error'
238
+ });
239
+ await log('❌ Cannot write to Claude executable (permission denied)', { level: 'error' });
240
+ await log(' Try running with sudo or changing file permissions', { level: 'error' });
241
+ process.exit(1);
242
+ }
243
+ // Read current shebang
244
+ const firstLine = await $`head -1 "${claudePath}"`;
245
+ const currentShebang = firstLine.stdout.toString().trim();
246
+ await log(` Current shebang: ${currentShebang}`);
247
+ if (currentShebang.includes('bun')) {
248
+ await log(' ✅ Claude is already configured to use bun');
249
+ process.exit(0);
250
+ }
251
+
252
+ // Create backup
253
+ const backupPath = `${claudePath}.nodejs-backup`;
254
+ await $`cp "${claudePath}" "${backupPath}"`;
255
+ await log(` 📦 Backup created: ${backupPath}`);
256
+
257
+ // Read file content and replace shebang
258
+ const content = await fs.readFile(claudePath, 'utf8');
259
+ const newContent = content.replace(/^#!.*node.*$/m, '#!/usr/bin/env bun');
260
+
261
+ if (content === newContent) {
262
+ await log('⚠️ No Node.js shebang found to replace', { level: 'warning' });
263
+ await log(` Current shebang: ${currentShebang}`, { level: 'warning' });
264
+ process.exit(0);
265
+ }
266
+
267
+ await fs.writeFile(claudePath, newContent);
268
+ await log(' ✅ Claude shebang updated to use bun');
269
+ await log(' 🔄 Claude will now run with bun runtime');
270
+
271
+ } catch (error) {
272
+ await log(`❌ Failed to switch Claude to bun: ${cleanErrorMessage(error)}`, { level: 'error' });
273
+ process.exit(1);
274
+ }
275
+
276
+ // Exit after switching runtime
277
+ process.exit(0);
278
+ }
279
+
280
+ if (argv['force-claude-nodejs-run']) {
281
+ await log('\n🔧 Restoring Claude runtime to Node.js...');
282
+ try {
283
+ try {
284
+ await $`which node`;
285
+ await log(' ✅ Node.js runtime found');
286
+ } catch (nodeError) {
287
+ reportError(nodeError, {
288
+ context: 'claude.lib.mjs - Node.js availability check',
289
+ level: 'error'
290
+ });
291
+ await log('❌ Node.js runtime not found. Please install Node.js first', { level: 'error' });
292
+ process.exit(1);
293
+ }
294
+
295
+ // Find Claude executable path
296
+ const claudePathResult = await $`which claude`;
297
+ const claudePath = claudePathResult.stdout.toString().trim();
298
+
299
+ if (!claudePath) {
300
+ await log('❌ Claude executable not found', { level: 'error' });
301
+ process.exit(1);
302
+ }
303
+
304
+ await log(` Claude path: ${claudePath}`);
305
+
306
+ try {
307
+ await fs.access(claudePath, fs.constants.W_OK);
308
+ } catch (accessError) {
309
+ reportError(accessError, {
310
+ context: 'claude.lib.mjs - Claude executable write permission check (nodejs)',
311
+ level: 'error'
312
+ });
313
+ await log('❌ Cannot write to Claude executable (permission denied)', { level: 'error' });
314
+ await log(' Try running with sudo or changing file permissions', { level: 'error' });
315
+ process.exit(1);
316
+ }
317
+ // Read current shebang
318
+ const firstLine = await $`head -1 "${claudePath}"`;
319
+ const currentShebang = firstLine.stdout.toString().trim();
320
+ await log(` Current shebang: ${currentShebang}`);
321
+ if (currentShebang.includes('node') && !currentShebang.includes('bun')) {
322
+ await log(' ✅ Claude is already configured to use Node.js');
323
+ process.exit(0);
324
+ }
325
+
326
+ const backupPath = `${claudePath}.nodejs-backup`;
327
+ try {
328
+ await fs.access(backupPath);
329
+ // Restore from backup
330
+ await $`cp "${backupPath}" "${claudePath}"`;
331
+ await log(` ✅ Restored Claude from backup: ${backupPath}`);
332
+ } catch (backupError) {
333
+ reportError(backupError, {
334
+ context: 'claude_restore_backup',
335
+ level: 'info'
336
+ });
337
+ // No backup available, manually update shebang
338
+ await log(' 📝 No backup found, manually updating shebang...');
339
+ const content = await fs.readFile(claudePath, 'utf8');
340
+ const newContent = content.replace(/^#!.*bun.*$/m, '#!/usr/bin/env node');
341
+
342
+ if (content === newContent) {
343
+ await log('⚠️ No bun shebang found to replace', { level: 'warning' });
344
+ await log(` Current shebang: ${currentShebang}`, { level: 'warning' });
345
+ process.exit(0);
346
+ }
347
+
348
+ await fs.writeFile(claudePath, newContent);
349
+ await log(' ✅ Claude shebang updated to use Node.js');
350
+ }
351
+
352
+ await log(' 🔄 Claude will now run with Node.js runtime');
353
+
354
+ } catch (error) {
355
+ await log(`❌ Failed to restore Claude to Node.js: ${cleanErrorMessage(error)}`, { level: 'error' });
356
+ process.exit(1);
357
+ }
358
+
359
+ // Exit after restoring runtime
360
+ process.exit(0);
361
+ }
362
+ };
363
+ /**
364
+ * Execute Claude with all prompts and settings
365
+ * This is the main entry point that handles all prompt building and execution
366
+ * @param {Object} params - Parameters for Claude execution
367
+ * @returns {Object} Result of the execution including success status and session info
368
+ */
369
+ export const executeClaude = async (params) => {
370
+ const {
371
+ issueUrl,
372
+ issueNumber,
373
+ prNumber,
374
+ prUrl,
375
+ branchName,
376
+ tempDir,
377
+ isContinueMode,
378
+ mergeStateStatus,
379
+ forkedRepo,
380
+ feedbackLines,
381
+ forkActionsUrl,
382
+ owner,
383
+ repo,
384
+ argv,
385
+ log,
386
+ setLogFile,
387
+ getLogFile,
388
+ formatAligned,
389
+ getResourceSnapshot,
390
+ claudePath,
391
+ $
392
+ } = params;
393
+ // Import prompt building functions from claude.prompts.lib.mjs
394
+ const { buildUserPrompt, buildSystemPrompt } = await import('./claude.prompts.lib.mjs');
395
+ // Build the user prompt
396
+ const prompt = buildUserPrompt({
397
+ issueUrl,
398
+ issueNumber,
399
+ prNumber,
400
+ prUrl,
401
+ branchName,
402
+ tempDir,
403
+ isContinueMode,
404
+ mergeStateStatus,
405
+ forkedRepo,
406
+ feedbackLines,
407
+ forkActionsUrl,
408
+ owner,
409
+ repo,
410
+ argv
411
+ });
412
+ // Build the system prompt
413
+ const systemPrompt = buildSystemPrompt({
414
+ owner,
415
+ repo,
416
+ issueNumber,
417
+ issueUrl,
418
+ prNumber,
419
+ prUrl,
420
+ branchName,
421
+ tempDir,
422
+ isContinueMode,
423
+ forkedRepo,
424
+ argv
425
+ });
426
+ // Log prompt details in verbose mode
427
+ if (argv.verbose) {
428
+ await log('\n📝 Final prompt structure:', { verbose: true });
429
+ await log(` Characters: ${prompt.length}`, { verbose: true });
430
+ await log(` System prompt characters: ${systemPrompt.length}`, { verbose: true });
431
+ if (feedbackLines && feedbackLines.length > 0) {
432
+ await log(' Feedback info: Included', { verbose: true });
433
+ }
434
+ // In dry-run mode, output the actual prompts for debugging
435
+ if (argv.dryRun) {
436
+ await log('\n📋 User prompt content:', { verbose: true });
437
+ await log('---BEGIN USER PROMPT---', { verbose: true });
438
+ await log(prompt, { verbose: true });
439
+ await log('---END USER PROMPT---', { verbose: true });
440
+ await log('\n📋 System prompt content:', { verbose: true });
441
+ await log('---BEGIN SYSTEM PROMPT---', { verbose: true });
442
+ await log(systemPrompt, { verbose: true });
443
+ await log('---END SYSTEM PROMPT---', { verbose: true });
444
+ }
445
+ }
446
+ // Escape prompts for shell usage
447
+ const escapedPrompt = prompt.replace(/"/g, '\\"').replace(/\$/g, '\\$');
448
+ const escapedSystemPrompt = systemPrompt.replace(/"/g, '\\"').replace(/\$/g, '\\$');
449
+ // Execute the Claude command
450
+ return await executeClaudeCommand({
451
+ tempDir,
452
+ branchName,
453
+ prompt,
454
+ systemPrompt,
455
+ escapedPrompt,
456
+ escapedSystemPrompt,
457
+ argv,
458
+ log,
459
+ setLogFile,
460
+ getLogFile,
461
+ formatAligned,
462
+ getResourceSnapshot,
463
+ forkedRepo,
464
+ feedbackLines,
465
+ claudePath,
466
+ $,
467
+ // For interactive mode
468
+ owner,
469
+ repo,
470
+ prNumber
471
+ });
472
+ };
473
+ /**
474
+ * Calculate total token usage from a session's JSONL file
475
+ * @param {string} sessionId - The session ID
476
+ * @param {string} tempDir - The temporary directory where the session ran
477
+ * @returns {Object} Token usage statistics
478
+ */
479
+ /**
480
+ * Fetches model information from pricing API
481
+ * @param {string} modelId - The model ID (e.g., "claude-sonnet-4-5-20250929")
482
+ * @returns {Promise<Object|null>} Model information or null if not found
483
+ */
484
+ export const fetchModelInfo = async (modelId) => {
485
+ try {
486
+ const https = (await use('https')).default;
487
+ return new Promise((resolve, reject) => {
488
+ https.get('https://models.dev/api.json', (res) => {
489
+ let data = '';
490
+ res.on('data', (chunk) => {
491
+ data += chunk;
492
+ });
493
+ res.on('end', () => {
494
+ try {
495
+ const apiData = JSON.parse(data);
496
+ // Search for the model across all providers
497
+ for (const provider of Object.values(apiData)) {
498
+ if (provider.models && provider.models[modelId]) {
499
+ const modelInfo = provider.models[modelId];
500
+ // Add provider info
501
+ modelInfo.provider = provider.name || provider.id;
502
+ resolve(modelInfo);
503
+ return;
504
+ }
505
+ }
506
+ // Model not found
507
+ resolve(null);
508
+ } catch (parseError) {
509
+ reject(parseError);
510
+ }
511
+ });
512
+ }).on('error', (err) => {
513
+ reject(err);
514
+ });
515
+ });
516
+ } catch {
517
+ // If we can't fetch model info, return null and continue without it
518
+ return null;
519
+ }
520
+ };
521
+ /**
522
+ * Calculate USD cost for a model's usage with detailed breakdown
523
+ * @param {Object} usage - Token usage object
524
+ * @param {Object} modelInfo - Model information from pricing API
525
+ * @param {boolean} includeBreakdown - Whether to include detailed calculation breakdown
526
+ * @returns {Object} Cost data with optional breakdown
527
+ */
528
+ export const calculateModelCost = (usage, modelInfo, includeBreakdown = false) => {
529
+ if (!modelInfo || !modelInfo.cost) {
530
+ return includeBreakdown ? { total: 0, breakdown: null } : 0;
531
+ }
532
+ const cost = modelInfo.cost;
533
+ const breakdown = {
534
+ input: { tokens: 0, costPerMillion: 0, cost: 0 },
535
+ cacheWrite: { tokens: 0, costPerMillion: 0, cost: 0 },
536
+ cacheRead: { tokens: 0, costPerMillion: 0, cost: 0 },
537
+ output: { tokens: 0, costPerMillion: 0, cost: 0 }
538
+ };
539
+ // Input tokens cost (per million tokens)
540
+ if (usage.inputTokens && cost.input) {
541
+ breakdown.input = {
542
+ tokens: usage.inputTokens,
543
+ costPerMillion: cost.input,
544
+ cost: (usage.inputTokens / 1000000) * cost.input
545
+ };
546
+ }
547
+ // Cache creation tokens cost
548
+ if (usage.cacheCreationTokens && cost.cache_write) {
549
+ breakdown.cacheWrite = {
550
+ tokens: usage.cacheCreationTokens,
551
+ costPerMillion: cost.cache_write,
552
+ cost: (usage.cacheCreationTokens / 1000000) * cost.cache_write
553
+ };
554
+ }
555
+ // Cache read tokens cost
556
+ if (usage.cacheReadTokens && cost.cache_read) {
557
+ breakdown.cacheRead = {
558
+ tokens: usage.cacheReadTokens,
559
+ costPerMillion: cost.cache_read,
560
+ cost: (usage.cacheReadTokens / 1000000) * cost.cache_read
561
+ };
562
+ }
563
+ // Output tokens cost
564
+ if (usage.outputTokens && cost.output) {
565
+ breakdown.output = {
566
+ tokens: usage.outputTokens,
567
+ costPerMillion: cost.output,
568
+ cost: (usage.outputTokens / 1000000) * cost.output
569
+ };
570
+ }
571
+ const totalCost = breakdown.input.cost + breakdown.cacheWrite.cost + breakdown.cacheRead.cost + breakdown.output.cost;
572
+ if (includeBreakdown) {
573
+ return {
574
+ total: totalCost,
575
+ breakdown
576
+ };
577
+ }
578
+ return totalCost;
579
+ };
580
+ /**
581
+ * Display detailed model usage information
582
+ * @param {Object} usage - Usage data for a model
583
+ * @param {Function} log - Logging function
584
+ */
585
+ const displayModelUsage = async (usage, log) => {
586
+ // Show all model characteristics if available
587
+ if (usage.modelInfo) {
588
+ const info = usage.modelInfo;
589
+ const fields = [
590
+ { label: 'Model ID', value: info.id },
591
+ { label: 'Provider', value: info.provider || 'Unknown' },
592
+ { label: 'Context window', value: info.limit?.context ? `${formatNumber(info.limit.context)} tokens` : null },
593
+ { label: 'Max output', value: info.limit?.output ? `${formatNumber(info.limit.output)} tokens` : null },
594
+ { label: 'Input modalities', value: info.modalities?.input?.join(', ') || 'N/A' },
595
+ { label: 'Output modalities', value: info.modalities?.output?.join(', ') || 'N/A' },
596
+ { label: 'Knowledge cutoff', value: info.knowledge },
597
+ { label: 'Released', value: info.release_date },
598
+ { label: 'Capabilities', value: [info.attachment && 'Attachments', info.reasoning && 'Reasoning', info.temperature && 'Temperature', info.tool_call && 'Tool calls'].filter(Boolean).join(', ') || 'N/A' },
599
+ { label: 'Open weights', value: info.open_weights ? 'Yes' : 'No' }
600
+ ];
601
+ for (const { label, value } of fields) {
602
+ if (value) await log(` ${label}: ${value}`);
603
+ }
604
+ await log('');
605
+ } else {
606
+ await log(' ⚠️ Model info not available\n');
607
+ }
608
+ // Show usage data
609
+ await log(' Usage:');
610
+ await log(` Input tokens: ${formatNumber(usage.inputTokens)}`);
611
+ if (usage.cacheCreationTokens > 0) {
612
+ await log(` Cache creation tokens: ${formatNumber(usage.cacheCreationTokens)}`);
613
+ }
614
+ if (usage.cacheReadTokens > 0) {
615
+ await log(` Cache read tokens: ${formatNumber(usage.cacheReadTokens)}`);
616
+ }
617
+ await log(` Output tokens: ${formatNumber(usage.outputTokens)}`);
618
+ if (usage.webSearchRequests > 0) {
619
+ await log(` Web search requests: ${usage.webSearchRequests}`);
620
+ }
621
+ // Show detailed cost calculation
622
+ if (usage.costUSD !== null && usage.costUSD !== undefined && usage.costBreakdown) {
623
+ await log('');
624
+ await log(' Cost Calculation (USD):');
625
+ const breakdown = usage.costBreakdown;
626
+ const types = [
627
+ { key: 'input', label: 'Input' },
628
+ { key: 'cacheWrite', label: 'Cache write' },
629
+ { key: 'cacheRead', label: 'Cache read' },
630
+ { key: 'output', label: 'Output' }
631
+ ];
632
+ for (const { key, label } of types) {
633
+ if (breakdown[key].tokens > 0) {
634
+ await log(` ${label}: ${formatNumber(breakdown[key].tokens)} tokens × $${breakdown[key].costPerMillion}/M = $${breakdown[key].cost.toFixed(6)}`);
635
+ }
636
+ }
637
+ await log(' ─────────────────────────────────');
638
+ await log(` Total: $${usage.costUSD.toFixed(6)}`);
639
+ } else if (usage.modelInfo === null) {
640
+ await log('');
641
+ await log(' Cost: Not available (could not fetch pricing)');
642
+ }
643
+ };
644
+ export const calculateSessionTokens = async (sessionId, tempDir) => {
645
+ const os = (await use('os')).default;
646
+ const homeDir = os.homedir();
647
+ // Construct the path to the session JSONL file
648
+ // Format: ~/.claude/projects/<project-dir>/<session-id>.jsonl
649
+ // The project directory name is the full path with slashes replaced by dashes
650
+ // e.g., /tmp/gh-issue-solver-123 becomes -tmp-gh-issue-solver-123
651
+ const projectDirName = tempDir.replace(/\//g, '-');
652
+ const sessionFile = path.join(homeDir, '.claude', 'projects', projectDirName, `${sessionId}.jsonl`);
653
+ try {
654
+ await fs.access(sessionFile);
655
+ } catch {
656
+ // File doesn't exist yet or can't be accessed
657
+ return null;
658
+ }
659
+ // Initialize per-model usage tracking
660
+ const modelUsage = {};
661
+ try {
662
+ // Read the entire file
663
+ const fileContent = await fs.readFile(sessionFile, 'utf8');
664
+ const lines = fileContent.trim().split('\n');
665
+ // Parse each line and accumulate token counts per model
666
+ for (const line of lines) {
667
+ if (!line.trim()) continue;
668
+ try {
669
+ const entry = JSON.parse(line);
670
+ if (entry.message && entry.message.usage && entry.message.model) {
671
+ const model = entry.message.model;
672
+ const usage = entry.message.usage;
673
+ // Initialize model entry if it doesn't exist
674
+ if (!modelUsage[model]) {
675
+ modelUsage[model] = {
676
+ inputTokens: 0,
677
+ cacheCreationTokens: 0,
678
+ cacheCreation5mTokens: 0,
679
+ cacheCreation1hTokens: 0,
680
+ cacheReadTokens: 0,
681
+ outputTokens: 0,
682
+ webSearchRequests: 0
683
+ };
684
+ }
685
+ // Add input tokens
686
+ if (usage.input_tokens) {
687
+ modelUsage[model].inputTokens += usage.input_tokens;
688
+ }
689
+ // Add cache creation tokens (total)
690
+ if (usage.cache_creation_input_tokens) {
691
+ modelUsage[model].cacheCreationTokens += usage.cache_creation_input_tokens;
692
+ }
693
+ // Add cache creation tokens breakdown (5m and 1h)
694
+ if (usage.cache_creation) {
695
+ if (usage.cache_creation.ephemeral_5m_input_tokens) {
696
+ modelUsage[model].cacheCreation5mTokens += usage.cache_creation.ephemeral_5m_input_tokens;
697
+ }
698
+ if (usage.cache_creation.ephemeral_1h_input_tokens) {
699
+ modelUsage[model].cacheCreation1hTokens += usage.cache_creation.ephemeral_1h_input_tokens;
700
+ }
701
+ }
702
+ // Add cache read tokens
703
+ if (usage.cache_read_input_tokens) {
704
+ modelUsage[model].cacheReadTokens += usage.cache_read_input_tokens;
705
+ }
706
+ // Add output tokens
707
+ if (usage.output_tokens) {
708
+ modelUsage[model].outputTokens += usage.output_tokens;
709
+ }
710
+ }
711
+ } catch {
712
+ // Skip lines that aren't valid JSON
713
+ continue;
714
+ }
715
+ }
716
+ // If no usage data was found, return null
717
+ if (Object.keys(modelUsage).length === 0) {
718
+ return null;
719
+ }
720
+ // Fetch model information for each model
721
+ const modelInfoPromises = Object.keys(modelUsage).map(async (modelId) => {
722
+ const modelInfo = await fetchModelInfo(modelId);
723
+ return { modelId, modelInfo };
724
+ });
725
+ const modelInfoResults = await Promise.all(modelInfoPromises);
726
+ const modelInfoMap = {};
727
+ for (const { modelId, modelInfo } of modelInfoResults) {
728
+ if (modelInfo) {
729
+ modelInfoMap[modelId] = modelInfo;
730
+ }
731
+ }
732
+ // Calculate cost for each model and store all characteristics
733
+ for (const [modelId, usage] of Object.entries(modelUsage)) {
734
+ const modelInfo = modelInfoMap[modelId];
735
+ // Calculate cost using pricing API
736
+ if (modelInfo) {
737
+ const costData = calculateModelCost(usage, modelInfo, true);
738
+ usage.costUSD = costData.total;
739
+ usage.costBreakdown = costData.breakdown;
740
+ usage.modelName = modelInfo.name || modelId;
741
+ usage.modelInfo = modelInfo; // Store complete model info
742
+ } else {
743
+ usage.costUSD = null;
744
+ usage.costBreakdown = null;
745
+ usage.modelName = modelId;
746
+ usage.modelInfo = null;
747
+ }
748
+ }
749
+ // Calculate grand totals across all models
750
+ let totalInputTokens = 0;
751
+ let totalCacheCreationTokens = 0;
752
+ let totalCacheReadTokens = 0;
753
+ let totalOutputTokens = 0;
754
+ let totalCostUSD = 0;
755
+ let hasCostData = false;
756
+ for (const usage of Object.values(modelUsage)) {
757
+ totalInputTokens += usage.inputTokens;
758
+ totalCacheCreationTokens += usage.cacheCreationTokens;
759
+ totalCacheReadTokens += usage.cacheReadTokens;
760
+ totalOutputTokens += usage.outputTokens;
761
+ if (usage.costUSD !== null) {
762
+ totalCostUSD += usage.costUSD;
763
+ hasCostData = true;
764
+ }
765
+ }
766
+ // Calculate total tokens (input + cache_creation + output, cache_read doesn't count as new tokens)
767
+ const totalTokens = totalInputTokens + totalCacheCreationTokens + totalOutputTokens;
768
+ return {
769
+ // Per-model breakdown
770
+ modelUsage,
771
+ // Grand totals
772
+ inputTokens: totalInputTokens,
773
+ cacheCreationTokens: totalCacheCreationTokens,
774
+ cacheReadTokens: totalCacheReadTokens,
775
+ outputTokens: totalOutputTokens,
776
+ totalTokens,
777
+ totalCostUSD: hasCostData ? totalCostUSD : null
778
+ };
779
+ } catch (readError) {
780
+ throw new Error(`Failed to read session file: ${readError.message}`);
781
+ }
782
+ };
783
+ export const executeClaudeCommand = async (params) => {
784
+ const {
785
+ tempDir,
786
+ branchName,
787
+ prompt,
788
+ systemPrompt,
789
+ escapedPrompt,
790
+ escapedSystemPrompt,
791
+ argv,
792
+ log,
793
+ setLogFile,
794
+ getLogFile,
795
+ formatAligned,
796
+ getResourceSnapshot,
797
+ forkedRepo,
798
+ feedbackLines,
799
+ claudePath,
800
+ $, // Add command-stream $ to params
801
+ // For interactive mode
802
+ owner,
803
+ repo,
804
+ prNumber
805
+ } = params;
806
+ // Retry configuration for API overload errors
807
+ const maxRetries = 3;
808
+ const baseDelay = timeouts.retryBaseDelay;
809
+ let retryCount = 0;
810
+ // Function to execute with retry logic
811
+ const executeWithRetry = async () => {
812
+ // Execute claude command from the cloned repository directory
813
+ if (retryCount === 0) {
814
+ await log(`\n${formatAligned('🤖', 'Executing Claude:', argv.model.toUpperCase())}`);
815
+ } else {
816
+ await log(`\n${formatAligned('🔄', 'Retry attempt:', `${retryCount}/${maxRetries}`)}`);
817
+ }
818
+ if (argv.verbose) {
819
+ // Output the actual model being used
820
+ const modelName = argv.model === 'opus' ? 'opus' : 'sonnet';
821
+ await log(` Model: ${modelName}`, { verbose: true });
822
+ await log(` Working directory: ${tempDir}`, { verbose: true });
823
+ await log(` Branch: ${branchName}`, { verbose: true });
824
+ await log(` Prompt length: ${prompt.length} chars`, { verbose: true });
825
+ await log(` System prompt length: ${systemPrompt.length} chars`, { verbose: true });
826
+ if (feedbackLines && feedbackLines.length > 0) {
827
+ await log(` Feedback info included: Yes (${feedbackLines.length} lines)`, { verbose: true });
828
+ } else {
829
+ await log(' Feedback info included: No', { verbose: true });
830
+ }
831
+ }
832
+ // Take resource snapshot before execution
833
+ const resourcesBefore = await getResourceSnapshot();
834
+ await log('📈 System resources before execution:', { verbose: true });
835
+ await log(` Memory: ${resourcesBefore.memory.split('\n')[1]}`, { verbose: true });
836
+ await log(` Load: ${resourcesBefore.load}`, { verbose: true });
837
+ // Use command-stream's async iteration for real-time streaming with file logging
838
+ let commandFailed = false;
839
+ let sessionId = null;
840
+ let limitReached = false;
841
+ let limitResetTime = null;
842
+ let messageCount = 0;
843
+ let toolUseCount = 0;
844
+ let lastMessage = '';
845
+ let isOverloadError = false;
846
+ let is503Error = false;
847
+ let stderrErrors = [];
848
+ let anthropicTotalCostUSD = null; // Capture Anthropic's official total_cost_usd from result
849
+
850
+ // Create interactive mode handler if enabled
851
+ let interactiveHandler = null;
852
+ if (argv.interactiveMode && owner && repo && prNumber) {
853
+ await log('🔌 Interactive mode: Creating handler for real-time PR comments', { verbose: true });
854
+ interactiveHandler = createInteractiveHandler({
855
+ owner,
856
+ repo,
857
+ prNumber,
858
+ $,
859
+ log,
860
+ verbose: argv.verbose
861
+ });
862
+ } else if (argv.interactiveMode) {
863
+ await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
864
+ }
865
+
866
+ // Build claude command with optional resume flag
867
+ let execCommand;
868
+ // Map model alias to full ID
869
+ const mappedModel = mapModelToId(argv.model);
870
+ // Build claude command arguments
871
+ let claudeArgs = `--output-format stream-json --verbose --dangerously-skip-permissions --model ${mappedModel}`;
872
+ if (argv.resume) {
873
+ await log(`🔄 Resuming from session: ${argv.resume}`);
874
+ claudeArgs = `--resume ${argv.resume} ${claudeArgs}`;
875
+ }
876
+ claudeArgs += ` -p "${escapedPrompt}" --append-system-prompt "${escapedSystemPrompt}"`;
877
+ // Build the full command for display (with jq for formatting as in v0.3.2)
878
+ const fullCommand = `(cd "${tempDir}" && ${claudePath} ${claudeArgs} | jq -c .)`;
879
+ // Print the actual raw command being executed
880
+ await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
881
+ await log(`${fullCommand}`);
882
+ await log('');
883
+ // Output prompts in verbose mode for debugging
884
+ if (argv.verbose) {
885
+ await log('📋 User prompt:', { verbose: true });
886
+ await log('---BEGIN USER PROMPT---', { verbose: true });
887
+ await log(prompt, { verbose: true });
888
+ await log('---END USER PROMPT---', { verbose: true });
889
+ await log('', { verbose: true });
890
+ await log('📋 System prompt:', { verbose: true });
891
+ await log('---BEGIN SYSTEM PROMPT---', { verbose: true });
892
+ await log(systemPrompt, { verbose: true });
893
+ await log('---END SYSTEM PROMPT---', { verbose: true });
894
+ await log('', { verbose: true });
895
+ }
896
+ try {
897
+ if (argv.resume) {
898
+ // When resuming, pass prompt directly with -p flag
899
+ // Use simpler escaping - just escape double quotes
900
+ const simpleEscapedPrompt = prompt.replace(/"/g, '\\"');
901
+ const simpleEscapedSystem = systemPrompt.replace(/"/g, '\\"');
902
+ execCommand = $({
903
+ cwd: tempDir,
904
+ mirror: false
905
+ })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${mappedModel} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
906
+ } else {
907
+ // When not resuming, pass prompt via stdin
908
+ // For system prompt, escape it properly for shell - just escape double quotes
909
+ const simpleEscapedSystem = systemPrompt.replace(/"/g, '\\"');
910
+ execCommand = $({
911
+ cwd: tempDir,
912
+ stdin: prompt,
913
+ mirror: false
914
+ })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${mappedModel} --append-system-prompt "${simpleEscapedSystem}"`;
915
+ }
916
+ await log(`${formatAligned('📋', 'Command details:', '')}`);
917
+ await log(formatAligned('📂', 'Working directory:', tempDir, 2));
918
+ await log(formatAligned('🌿', 'Branch:', branchName, 2));
919
+ await log(formatAligned('🤖', 'Model:', `Claude ${argv.model.toUpperCase()}`, 2));
920
+ if (argv.fork && forkedRepo) {
921
+ await log(formatAligned('🍴', 'Fork:', forkedRepo, 2));
922
+ }
923
+ await log(`\n${formatAligned('▶️', 'Streaming output:', '')}\n`);
924
+ // Use command-stream's async iteration for real-time streaming
925
+ let exitCode = 0;
926
+ for await (const chunk of execCommand.stream()) {
927
+ if (chunk.type === 'stdout') {
928
+ const output = chunk.data.toString();
929
+ // Split output into individual lines for NDJSON parsing
930
+ // Claude CLI outputs NDJSON (newline-delimited JSON) format where each line is a separate JSON object
931
+ // This allows us to parse each event independently and extract structured data like session IDs,
932
+ // message counts, and error patterns. Attempting to parse the entire chunk as single JSON would fail
933
+ // since multiple JSON objects aren't valid JSON together.
934
+ const lines = output.split('\n');
935
+ for (const line of lines) {
936
+ if (!line.trim()) continue;
937
+ try {
938
+ const data = JSON.parse(line);
939
+ // Process event in interactive mode (posts PR comments in real-time)
940
+ if (interactiveHandler) {
941
+ try {
942
+ await interactiveHandler.processEvent(data);
943
+ } catch (interactiveError) {
944
+ // Don't let interactive mode errors stop the main execution
945
+ await log(`⚠️ Interactive mode error: ${interactiveError.message}`, { verbose: true });
946
+ }
947
+ }
948
+ // Output formatted JSON as in v0.3.2
949
+ await log(JSON.stringify(data, null, 2));
950
+ // Capture session ID from the first message
951
+ if (!sessionId && data.session_id) {
952
+ sessionId = data.session_id;
953
+ await log(`📌 Session ID: ${sessionId}`);
954
+ // Try to rename log file to include session ID
955
+ let sessionLogFile;
956
+ try {
957
+ const currentLogFile = getLogFile();
958
+ const logDir = path.dirname(currentLogFile);
959
+ sessionLogFile = path.join(logDir, `${sessionId}.log`);
960
+ // Use fs.promises to rename the file
961
+ await fs.rename(currentLogFile, sessionLogFile);
962
+ // Update the global log file reference
963
+ setLogFile(sessionLogFile);
964
+ await log(`📁 Log renamed to: ${sessionLogFile}`);
965
+ } catch (renameError) {
966
+ reportError(renameError, {
967
+ context: 'rename_session_log',
968
+ sessionId,
969
+ sessionLogFile,
970
+ operation: 'rename_log_file'
971
+ });
972
+ // If rename fails, keep original filename
973
+ await log(`⚠️ Could not rename log file: ${renameError.message}`, { verbose: true });
974
+ }
975
+ }
976
+ // Track message and tool use counts
977
+ if (data.type === 'message') {
978
+ messageCount++;
979
+ } else if (data.type === 'tool_use') {
980
+ toolUseCount++;
981
+ }
982
+ // Handle session result type from Claude CLI
983
+ // This is emitted when a session completes, either successfully or with an error
984
+ // Example: {"type": "result", "subtype": "success", "is_error": true, "result": "Session limit reached ∙ resets 10am"}
985
+ if (data.type === 'result') {
986
+ // Capture Anthropic's official total_cost_usd from the result
987
+ if (data.total_cost_usd !== undefined && data.total_cost_usd !== null) {
988
+ anthropicTotalCostUSD = data.total_cost_usd;
989
+ await log(`💰 Anthropic official cost captured: $${anthropicTotalCostUSD.toFixed(6)}`, { verbose: true });
990
+ }
991
+ if (data.is_error === true) {
992
+ commandFailed = true;
993
+ lastMessage = data.result || JSON.stringify(data);
994
+ await log('⚠️ Detected error result from Claude CLI', { verbose: true });
995
+ if (lastMessage.includes('Session limit reached') || lastMessage.includes('limit reached')) {
996
+ limitReached = true;
997
+ await log('⚠️ Detected session limit in result', { verbose: true });
998
+ }
999
+ }
1000
+ }
1001
+ // Store last message for error detection
1002
+ if (data.type === 'text' && data.text) {
1003
+ lastMessage = data.text;
1004
+ } else if (data.type === 'error') {
1005
+ lastMessage = data.error || JSON.stringify(data);
1006
+ }
1007
+ // Check for API overload error and 503 errors
1008
+ if (data.type === 'assistant' && data.message && data.message.content) {
1009
+ const content = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
1010
+ for (const item of content) {
1011
+ if (item.type === 'text' && item.text) {
1012
+ // Check for the specific 500 overload error pattern
1013
+ if (item.text.includes('API Error: 500') &&
1014
+ item.text.includes('api_error') &&
1015
+ item.text.includes('Overloaded')) {
1016
+ isOverloadError = true;
1017
+ lastMessage = item.text;
1018
+ await log('⚠️ Detected API overload error', { verbose: true });
1019
+ }
1020
+ // Check for 503 errors
1021
+ if (item.text.includes('API Error: 503') ||
1022
+ (item.text.includes('503') && item.text.includes('upstream connect error')) ||
1023
+ (item.text.includes('503') && item.text.includes('remote connection failure'))) {
1024
+ is503Error = true;
1025
+ lastMessage = item.text;
1026
+ await log('⚠️ Detected 503 network error', { verbose: true });
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+ } catch (parseError) {
1032
+ // JSON parse errors are expected for non-JSON output
1033
+ // Only report in verbose mode
1034
+ if (global.verboseMode) {
1035
+ reportError(parseError, {
1036
+ context: 'parse_claude_output',
1037
+ line,
1038
+ operation: 'parse_json_output',
1039
+ level: 'debug'
1040
+ });
1041
+ }
1042
+ // Not JSON or parsing failed, output as-is if it's not empty
1043
+ if (line.trim() && !line.includes('node:internal')) {
1044
+ await log(line, { stream: 'raw' });
1045
+ lastMessage = line;
1046
+ }
1047
+ }
1048
+ }
1049
+ }
1050
+ if (chunk.type === 'stderr') {
1051
+ const errorOutput = chunk.data.toString();
1052
+ // Log stderr immediately
1053
+ if (errorOutput) {
1054
+ await log(errorOutput, { stream: 'stderr' });
1055
+ // Track stderr errors for failure detection
1056
+ const trimmed = errorOutput.trim();
1057
+ // Exclude warnings (messages starting with ⚠️) from being treated as errors
1058
+ // Example: "⚠️ [BashTool] Pre-flight check is taking longer than expected. Run with ANTHROPIC_LOG=debug to check for failed or slow API requests."
1059
+ // Even though this contains the word "failed", it's a warning, not an error
1060
+ const isWarning = trimmed.startsWith('⚠️') || trimmed.startsWith('⚠');
1061
+ if (trimmed && !isWarning && (trimmed.includes('Error:') || trimmed.includes('error') || trimmed.includes('failed'))) {
1062
+ stderrErrors.push(trimmed);
1063
+ }
1064
+ }
1065
+ } else if (chunk.type === 'exit') {
1066
+ exitCode = chunk.code;
1067
+ if (chunk.code !== 0) {
1068
+ commandFailed = true;
1069
+ }
1070
+ // Don't break here - let the loop finish naturally to process all output
1071
+ }
1072
+ }
1073
+
1074
+ // Flush any remaining queued comments from interactive mode
1075
+ if (interactiveHandler) {
1076
+ try {
1077
+ await interactiveHandler.flush();
1078
+ } catch (flushError) {
1079
+ await log(`⚠️ Interactive mode flush error: ${flushError.message}`, { verbose: true });
1080
+ }
1081
+ }
1082
+
1083
+ if ((commandFailed || isOverloadError) &&
1084
+ (isOverloadError ||
1085
+ (lastMessage.includes('API Error: 500') && lastMessage.includes('Overloaded')) ||
1086
+ (lastMessage.includes('api_error') && lastMessage.includes('Overloaded')))) {
1087
+ if (retryCount < maxRetries) {
1088
+ // Calculate exponential backoff delay
1089
+ const delay = baseDelay * Math.pow(2, retryCount);
1090
+ await log(`\n⚠️ API overload error detected. Retrying in ${delay / 1000} seconds...`, { level: 'warning' });
1091
+ await log(` Error: ${lastMessage.substring(0, 200)}`, { verbose: true });
1092
+ // Wait before retrying
1093
+ await new Promise(resolve => setTimeout(resolve, delay));
1094
+ // Increment retry count and retry
1095
+ retryCount++;
1096
+ return await executeWithRetry();
1097
+ } else {
1098
+ await log(`\n\n❌ API overload error persisted after ${maxRetries} retries`, { level: 'error' });
1099
+ await log(' The API appears to be heavily loaded. Please try again later.', { level: 'error' });
1100
+ return {
1101
+ success: false,
1102
+ sessionId,
1103
+ limitReached: false,
1104
+ limitResetTime: null,
1105
+ messageCount,
1106
+ toolUseCount
1107
+ };
1108
+ }
1109
+ }
1110
+ if ((commandFailed || is503Error) && argv.autoResumeOnErrors &&
1111
+ (is503Error ||
1112
+ lastMessage.includes('API Error: 503') ||
1113
+ (lastMessage.includes('503') && lastMessage.includes('upstream connect error')) ||
1114
+ (lastMessage.includes('503') && lastMessage.includes('remote connection failure')))) {
1115
+ if (retryCount < retryLimits.max503Retries) {
1116
+ // Calculate exponential backoff delay starting from 5 minutes
1117
+ const delay = retryLimits.initial503RetryDelayMs * Math.pow(retryLimits.retryBackoffMultiplier, retryCount);
1118
+ const delayMinutes = Math.round(delay / (1000 * 60));
1119
+ await log(`\n⚠️ 503 network error detected. Retrying in ${delayMinutes} minutes...`, { level: 'warning' });
1120
+ await log(` Error: ${lastMessage.substring(0, 200)}`, { verbose: true });
1121
+ await log(` Retry ${retryCount + 1}/${retryLimits.max503Retries}`, { verbose: true });
1122
+ // Show countdown for long waits
1123
+ if (delay > 60000) {
1124
+ const countdownInterval = 60000; // Every minute
1125
+ let remainingMs = delay;
1126
+ const countdownTimer = setInterval(async () => {
1127
+ remainingMs -= countdownInterval;
1128
+ if (remainingMs > 0) {
1129
+ const remainingMinutes = Math.round(remainingMs / (1000 * 60));
1130
+ await log(`⏳ ${remainingMinutes} minutes remaining until retry...`);
1131
+ }
1132
+ }, countdownInterval);
1133
+ // Wait before retrying
1134
+ await new Promise(resolve => setTimeout(resolve, delay));
1135
+ clearInterval(countdownTimer);
1136
+ } else {
1137
+ // Wait before retrying
1138
+ await new Promise(resolve => setTimeout(resolve, delay));
1139
+ }
1140
+ await log('\n🔄 Retrying now...');
1141
+ // Increment retry count and retry
1142
+ retryCount++;
1143
+ return await executeWithRetry();
1144
+ } else {
1145
+ await log(`\n\n❌ 503 network error persisted after ${retryLimits.max503Retries} retries`, { level: 'error' });
1146
+ await log(' The Anthropic API appears to be experiencing network issues.', { level: 'error' });
1147
+ await log(' Please try again later or check https://status.anthropic.com/', { level: 'error' });
1148
+ return {
1149
+ success: false,
1150
+ sessionId,
1151
+ limitReached: false,
1152
+ limitResetTime: null,
1153
+ messageCount,
1154
+ toolUseCount,
1155
+ is503Error: true
1156
+ };
1157
+ }
1158
+ }
1159
+ if (commandFailed) {
1160
+ // Check for usage limit errors first (more specific)
1161
+ const limitInfo = detectUsageLimit(lastMessage);
1162
+ if (limitInfo.isUsageLimit) {
1163
+ limitReached = true;
1164
+ limitResetTime = limitInfo.resetTime;
1165
+
1166
+ // Format and display user-friendly message
1167
+ const messageLines = formatUsageLimitMessage({
1168
+ tool: 'Claude',
1169
+ resetTime: limitInfo.resetTime,
1170
+ sessionId,
1171
+ resumeCommand: argv.url ? `${process.argv[0]} ${process.argv[1]} --auto-continue ${argv.url}` : null
1172
+ });
1173
+
1174
+ for (const line of messageLines) {
1175
+ await log(line, { level: 'warning' });
1176
+ }
1177
+ } else if (lastMessage.includes('context_length_exceeded')) {
1178
+ await log('\n\n❌ Context length exceeded. Try with a smaller issue or split the work.', { level: 'error' });
1179
+ } else {
1180
+ await log(`\n\n❌ Claude command failed with exit code ${exitCode}`, { level: 'error' });
1181
+ if (sessionId && !argv.resume) {
1182
+ await log(`📌 Session ID for resuming: ${sessionId}`);
1183
+ await log('\nTo resume this session, run:');
1184
+ await log(` ${process.argv[0]} ${process.argv[1]} ${argv.url} --resume ${sessionId}`);
1185
+ }
1186
+ }
1187
+ }
1188
+ // Additional failure detection: if no messages were processed and there were stderr errors,
1189
+ // or if the command produced no output at all, treat it as a failure
1190
+ //
1191
+ // This is critical for detecting "silent failures" where:
1192
+ // 1. Claude CLI encounters an internal error (e.g., "kill EPERM" from timeout)
1193
+ // 2. The error is logged to stderr but exit code is 0 or exit event is never sent
1194
+ // 3. Result: messageCount=0, toolUseCount=0, but stderrErrors has content
1195
+ //
1196
+ // Common cause: sudo commands that timeout
1197
+ // - Timeout triggers process.kill() in Claude CLI
1198
+ // - If child process runs with sudo (root), parent can't kill it → EPERM error
1199
+ // - Error logged to stderr, but command doesn't properly fail
1200
+ //
1201
+ // Workaround (applied in system prompt):
1202
+ // - Instruct Claude to run sudo commands (installations) in background
1203
+ // - Background processes avoid timeout kill mechanism
1204
+ // - Prevents EPERM errors and false success reports
1205
+ //
1206
+ // See: docs/dependencies-research/claude-code-issues/README.md for full details
1207
+ if (!commandFailed && stderrErrors.length > 0 && messageCount === 0 && toolUseCount === 0) {
1208
+ commandFailed = true;
1209
+ await log('\n\n❌ Command failed: No messages processed and errors detected in stderr', { level: 'error' });
1210
+ await log('Stderr errors:', { level: 'error' });
1211
+ for (const err of stderrErrors.slice(0, 5)) {
1212
+ await log(` ${err.substring(0, 200)}`, { level: 'error' });
1213
+ }
1214
+ }
1215
+ if (commandFailed) {
1216
+ // Take resource snapshot after failure
1217
+ const resourcesAfter = await getResourceSnapshot();
1218
+ await log('\n📈 System resources after execution:', { verbose: true });
1219
+ await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
1220
+ await log(` Load: ${resourcesAfter.load}`, { verbose: true });
1221
+ // Log attachment will be handled by solve.mjs when it receives success=false
1222
+ await log('', { verbose: true });
1223
+ return {
1224
+ success: false,
1225
+ sessionId,
1226
+ limitReached,
1227
+ limitResetTime,
1228
+ messageCount,
1229
+ toolUseCount
1230
+ };
1231
+ }
1232
+ await log('\n\n✅ Claude command completed');
1233
+ await log(`📊 Total messages: ${messageCount}, Tool uses: ${toolUseCount}`);
1234
+ // Calculate and display total token usage from session JSONL file
1235
+ if (sessionId && tempDir) {
1236
+ try {
1237
+ const tokenUsage = await calculateSessionTokens(sessionId, tempDir);
1238
+ if (tokenUsage) {
1239
+ await log('\n💰 Token Usage Summary:');
1240
+ // Display per-model breakdown
1241
+ if (tokenUsage.modelUsage) {
1242
+ const modelIds = Object.keys(tokenUsage.modelUsage);
1243
+ for (const modelId of modelIds) {
1244
+ const usage = tokenUsage.modelUsage[modelId];
1245
+ await log(`\n 📊 ${usage.modelName || modelId}:`);
1246
+ await displayModelUsage(usage, log);
1247
+ }
1248
+ // Show totals if multiple models were used
1249
+ if (modelIds.length > 1) {
1250
+ await log('\n 📈 Total across all models:');
1251
+ // Show cost comparison
1252
+ await log('\n 💰 Cost estimation:');
1253
+ if (tokenUsage.totalCostUSD !== null && tokenUsage.totalCostUSD !== undefined) {
1254
+ await log(` Public pricing estimate: $${tokenUsage.totalCostUSD.toFixed(6)} USD`);
1255
+ } else {
1256
+ await log(' Public pricing estimate: unknown');
1257
+ }
1258
+ if (anthropicTotalCostUSD !== null && anthropicTotalCostUSD !== undefined) {
1259
+ await log(` Calculated by Anthropic: $${anthropicTotalCostUSD.toFixed(6)} USD`);
1260
+ // Show comparison if both are available
1261
+ if (tokenUsage.totalCostUSD !== null && tokenUsage.totalCostUSD !== undefined) {
1262
+ const difference = anthropicTotalCostUSD - tokenUsage.totalCostUSD;
1263
+ const percentDiff = tokenUsage.totalCostUSD > 0 ? ((difference / tokenUsage.totalCostUSD) * 100) : 0;
1264
+ await log(` Difference: $${difference.toFixed(6)} (${percentDiff > 0 ? '+' : ''}${percentDiff.toFixed(2)}%)`);
1265
+ } else {
1266
+ await log(' Difference: unknown');
1267
+ }
1268
+ } else {
1269
+ await log(' Calculated by Anthropic: unknown');
1270
+ await log(' Difference: unknown');
1271
+ }
1272
+ } else {
1273
+ // Single model - show cost comparison
1274
+ await log('\n 💰 Cost estimation:');
1275
+ if (tokenUsage.totalCostUSD !== null && tokenUsage.totalCostUSD !== undefined) {
1276
+ await log(` Public pricing estimate: $${tokenUsage.totalCostUSD.toFixed(6)} USD`);
1277
+ } else {
1278
+ await log(' Public pricing estimate: unknown');
1279
+ }
1280
+ if (anthropicTotalCostUSD !== null && anthropicTotalCostUSD !== undefined) {
1281
+ await log(` Calculated by Anthropic: $${anthropicTotalCostUSD.toFixed(6)} USD`);
1282
+ // Show comparison if both are available
1283
+ if (tokenUsage.totalCostUSD !== null && tokenUsage.totalCostUSD !== undefined) {
1284
+ const difference = anthropicTotalCostUSD - tokenUsage.totalCostUSD;
1285
+ const percentDiff = tokenUsage.totalCostUSD > 0 ? ((difference / tokenUsage.totalCostUSD) * 100) : 0;
1286
+ await log(` Difference: $${difference.toFixed(6)} (${percentDiff > 0 ? '+' : ''}${percentDiff.toFixed(2)}%)`);
1287
+ } else {
1288
+ await log(' Difference: unknown');
1289
+ }
1290
+ } else {
1291
+ await log(' Calculated by Anthropic: unknown');
1292
+ await log(' Difference: unknown');
1293
+ }
1294
+ await log(` Total tokens: ${formatNumber(tokenUsage.totalTokens)}`);
1295
+ }
1296
+ } else {
1297
+ // Fallback to old format if modelUsage is not available
1298
+ await log(` Input tokens: ${formatNumber(tokenUsage.inputTokens)}`);
1299
+ if (tokenUsage.cacheCreationTokens > 0) {
1300
+ await log(` Cache creation tokens: ${formatNumber(tokenUsage.cacheCreationTokens)}`);
1301
+ }
1302
+ if (tokenUsage.cacheReadTokens > 0) {
1303
+ await log(` Cache read tokens: ${formatNumber(tokenUsage.cacheReadTokens)}`);
1304
+ }
1305
+ await log(` Output tokens: ${formatNumber(tokenUsage.outputTokens)}`);
1306
+ await log(` Total tokens: ${formatNumber(tokenUsage.totalTokens)}`);
1307
+ }
1308
+ }
1309
+ } catch (tokenError) {
1310
+ reportError(tokenError, {
1311
+ context: 'calculate_session_tokens',
1312
+ sessionId,
1313
+ operation: 'read_session_jsonl'
1314
+ });
1315
+ await log(` ⚠️ Could not calculate token usage: ${tokenError.message}`, { verbose: true });
1316
+ }
1317
+ }
1318
+ return {
1319
+ success: true,
1320
+ sessionId,
1321
+ limitReached,
1322
+ limitResetTime,
1323
+ messageCount,
1324
+ toolUseCount,
1325
+ anthropicTotalCostUSD // Pass Anthropic's official total cost
1326
+ };
1327
+ } catch (error) {
1328
+ reportError(error, {
1329
+ context: 'execute_claude',
1330
+ command: params.command,
1331
+ claudePath: params.claudePath,
1332
+ operation: 'run_claude_command'
1333
+ });
1334
+ const errorStr = error.message || error.toString();
1335
+ if ((errorStr.includes('API Error: 500') && errorStr.includes('Overloaded')) ||
1336
+ (errorStr.includes('api_error') && errorStr.includes('Overloaded'))) {
1337
+ if (retryCount < maxRetries) {
1338
+ // Calculate exponential backoff delay
1339
+ const delay = baseDelay * Math.pow(2, retryCount);
1340
+ await log(`\n⚠️ API overload error in exception. Retrying in ${delay / 1000} seconds...`, { level: 'warning' });
1341
+ // Wait before retrying
1342
+ await new Promise(resolve => setTimeout(resolve, delay));
1343
+ // Increment retry count and retry
1344
+ retryCount++;
1345
+ return await executeWithRetry();
1346
+ }
1347
+ }
1348
+ if (argv.autoResumeOnErrors &&
1349
+ (errorStr.includes('API Error: 503') ||
1350
+ (errorStr.includes('503') && errorStr.includes('upstream connect error')) ||
1351
+ (errorStr.includes('503') && errorStr.includes('remote connection failure')))) {
1352
+ if (retryCount < retryLimits.max503Retries) {
1353
+ // Calculate exponential backoff delay starting from 5 minutes
1354
+ const delay = retryLimits.initial503RetryDelayMs * Math.pow(retryLimits.retryBackoffMultiplier, retryCount);
1355
+ const delayMinutes = Math.round(delay / (1000 * 60));
1356
+ await log(`\n⚠️ 503 network error in exception. Retrying in ${delayMinutes} minutes...`, { level: 'warning' });
1357
+ // Wait before retrying
1358
+ await new Promise(resolve => setTimeout(resolve, delay));
1359
+ // Increment retry count and retry
1360
+ retryCount++;
1361
+ return await executeWithRetry();
1362
+ }
1363
+ }
1364
+ await log(`\n\n❌ Error executing Claude command: ${error.message}`, { level: 'error' });
1365
+ return {
1366
+ success: false,
1367
+ sessionId,
1368
+ limitReached,
1369
+ limitResetTime: null,
1370
+ messageCount,
1371
+ toolUseCount
1372
+ };
1373
+ }
1374
+ }; // End of executeWithRetry function
1375
+ // Start the execution with retry logic
1376
+ return await executeWithRetry();
1377
+ };
1378
+ export const checkForUncommittedChanges = async (tempDir, owner, repo, branchName, $, log, autoCommit = false, autoRestartEnabled = true) => {
1379
+ await log('\n🔍 Checking for uncommitted changes...');
1380
+ try {
1381
+ const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
1382
+ if (gitStatusResult.code === 0) {
1383
+ const statusOutput = gitStatusResult.stdout.toString().trim();
1384
+ if (statusOutput) {
1385
+ await log('📝 Found uncommitted changes');
1386
+ await log('Changes:');
1387
+ for (const line of statusOutput.split('\n')) {
1388
+ await log(` ${line}`);
1389
+ }
1390
+ if (autoCommit) {
1391
+ await log('💾 Auto-committing changes (--auto-commit-uncommitted-changes is enabled)...');
1392
+ const addResult = await $({ cwd: tempDir })`git add -A`;
1393
+ if (addResult.code === 0) {
1394
+ const commitMessage = 'Auto-commit: Changes made by Claude during problem-solving session';
1395
+ const commitResult = await $({ cwd: tempDir })`git commit -m ${commitMessage}`;
1396
+ if (commitResult.code === 0) {
1397
+ await log('✅ Changes committed successfully');
1398
+ await log('📤 Pushing changes to remote...');
1399
+ const pushResult = await $({ cwd: tempDir })`git push origin ${branchName}`;
1400
+ if (pushResult.code === 0) {
1401
+ await log('✅ Changes pushed successfully');
1402
+ } else {
1403
+ await log(`⚠️ Warning: Could not push changes: ${pushResult.stderr?.toString().trim()}`, { level: 'warning' });
1404
+ }
1405
+ } else {
1406
+ await log(`⚠️ Warning: Could not commit changes: ${commitResult.stderr?.toString().trim()}`, { level: 'warning' });
1407
+ }
1408
+ } else {
1409
+ await log(`⚠️ Warning: Could not stage changes: ${addResult.stderr?.toString().trim()}`, { level: 'warning' });
1410
+ }
1411
+ return false;
1412
+ } else if (autoRestartEnabled) {
1413
+ await log('\n⚠️ IMPORTANT: Uncommitted changes detected!');
1414
+ await log(' Claude made changes that were not committed.\n');
1415
+ await log('🔄 AUTO-RESTART: Restarting Claude to handle uncommitted changes...');
1416
+ await log(' Claude will review the changes and decide what to commit.\n');
1417
+ return true;
1418
+ } else {
1419
+ await log('\n⚠️ Uncommitted changes detected but auto-restart is disabled.');
1420
+ await log(' Use --auto-restart-on-uncommitted-changes to enable or commit manually.\n');
1421
+ return false;
1422
+ }
1423
+ } else {
1424
+ await log('✅ No uncommitted changes found');
1425
+ return false;
1426
+ }
1427
+ } else {
1428
+ await log(`⚠️ Warning: Could not check git status: ${gitStatusResult.stderr?.toString().trim()}`, { level: 'warning' });
1429
+ return false;
1430
+ }
1431
+ } catch (gitError) {
1432
+ reportError(gitError, { context: 'check_uncommitted_changes', tempDir, operation: 'git_status_check' });
1433
+ await log(`⚠️ Warning: Error checking for uncommitted changes: ${gitError.message}`, { level: 'warning' });
1434
+ return false;
1435
+ }
1436
+ };
1437
+ // Export all functions as default object too
1438
+ export default {
1439
+ validateClaudeConnection,
1440
+ handleClaudeRuntimeSwitch,
1441
+ executeClaude,
1442
+ executeClaudeCommand,
1443
+ checkForUncommittedChanges,
1444
+ calculateSessionTokens
1445
+ };