@xelth/eck-snapshot 2.2.0 → 4.0.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.
- package/LICENSE +21 -0
- package/README.md +119 -225
- package/index.js +14 -776
- package/package.json +25 -7
- package/setup.json +805 -0
- package/src/cli/cli.js +427 -0
- package/src/cli/commands/askGpt.js +29 -0
- package/src/cli/commands/autoDocs.js +150 -0
- package/src/cli/commands/consilium.js +86 -0
- package/src/cli/commands/createSnapshot.js +601 -0
- package/src/cli/commands/detectProfiles.js +98 -0
- package/src/cli/commands/detectProject.js +112 -0
- package/src/cli/commands/generateProfileGuide.js +91 -0
- package/src/cli/commands/pruneSnapshot.js +106 -0
- package/src/cli/commands/restoreSnapshot.js +173 -0
- package/src/cli/commands/setupGemini.js +149 -0
- package/src/cli/commands/setupGemini.test.js +115 -0
- package/src/cli/commands/trainTokens.js +38 -0
- package/src/config.js +81 -0
- package/src/services/authService.js +20 -0
- package/src/services/claudeCliService.js +621 -0
- package/src/services/claudeCliService.test.js +267 -0
- package/src/services/dispatcherService.js +33 -0
- package/src/services/gptService.js +302 -0
- package/src/services/gptService.test.js +120 -0
- package/src/templates/agent-prompt.template.md +29 -0
- package/src/templates/architect-prompt.template.md +50 -0
- package/src/templates/envScanRequest.md +4 -0
- package/src/templates/gitWorkflow.md +32 -0
- package/src/templates/multiAgent.md +164 -0
- package/src/templates/vectorMode.md +22 -0
- package/src/utils/aiHeader.js +303 -0
- package/src/utils/fileUtils.js +928 -0
- package/src/utils/projectDetector.js +704 -0
- package/src/utils/tokenEstimator.js +198 -0
- package/.ecksnapshot.config.js +0 -35
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import pRetry from 'p-retry';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Executes a prompt using the claude-code CLI in non-interactive print mode.
|
|
7
|
+
* @param {string} prompt The prompt to send to Claude.
|
|
8
|
+
* @param {object} options Options object, e.g., { continueConversation: boolean, taskSize: number }.
|
|
9
|
+
* @returns {Promise<object>} A promise that resolves with the final JSON output object from Claude.
|
|
10
|
+
*/
|
|
11
|
+
export async function executePrompt(prompt, options = {}) {
|
|
12
|
+
const { continueConversation = false } = options;
|
|
13
|
+
try {
|
|
14
|
+
// Ensure the log directory exists
|
|
15
|
+
try {
|
|
16
|
+
await import('fs/promises').then(fs => fs.mkdir('./.eck/logs', { recursive: true }));
|
|
17
|
+
} catch (e) {
|
|
18
|
+
console.error(`Failed to create log directory: ${e.message}`);
|
|
19
|
+
// Do not block execution if log dir creation fails, just warn
|
|
20
|
+
}
|
|
21
|
+
let sessionId = null;
|
|
22
|
+
if (continueConversation) {
|
|
23
|
+
sessionId = await getLastSessionId();
|
|
24
|
+
if (!sessionId) {
|
|
25
|
+
console.warn('No previous session found, starting new conversation');
|
|
26
|
+
} else {
|
|
27
|
+
console.log(`Continuing conversation with session: ${sessionId}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return await attemptClaudeExecution(prompt, sessionId, options);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// Check for claude session limits first
|
|
34
|
+
if (isSessionLimitError(error)) {
|
|
35
|
+
await logSessionLimitError(error, prompt);
|
|
36
|
+
throw new Error(`Claude session limit reached: ${error.message}. Please take a break and try again later.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// If the first attempt fails (timeout, interactive prompts, etc), try to handle it
|
|
40
|
+
if (error.message.includes('timeout') || error.message.includes('SIGTERM')) {
|
|
41
|
+
console.log('First attempt failed, attempting interactive recovery...');
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
// Try running claude interactively to see what prompts appear
|
|
45
|
+
const interactiveResult = await execa('claude', [], {
|
|
46
|
+
input: '\n',
|
|
47
|
+
timeout: 10000,
|
|
48
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Логируем любое интерактивное взаимодействие
|
|
52
|
+
const interactiveLogFile = `./.eck/logs/claude-interactive-${Date.now()}.log`;
|
|
53
|
+
const interactiveLogContent = `=== Claude Interactive Recovery Log ${new Date().toISOString()} ===\n` +
|
|
54
|
+
`Original prompt: "${prompt}"\n` +
|
|
55
|
+
`Original error: ${error.message}\n` +
|
|
56
|
+
`Recovery command: claude (with newline input)\n` +
|
|
57
|
+
`STDOUT:\n${interactiveResult.stdout}\n` +
|
|
58
|
+
`STDERR:\n${interactiveResult.stderr}\n` +
|
|
59
|
+
`=== End Interactive Log ===\n\n`;
|
|
60
|
+
|
|
61
|
+
await import('fs/promises').then(fs => fs.appendFile(interactiveLogFile, interactiveLogContent, 'utf8'));
|
|
62
|
+
console.log(`Interactive recovery logged to: ${interactiveLogFile}`);
|
|
63
|
+
|
|
64
|
+
// Wait a moment for any setup to be processed
|
|
65
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
66
|
+
|
|
67
|
+
// Now try the original prompt again
|
|
68
|
+
return await attemptClaudeExecution(prompt, sessionId, options);
|
|
69
|
+
} catch (retryError) {
|
|
70
|
+
// Логируем неудачу восстановления
|
|
71
|
+
const failureLogFile = `./.eck/logs/claude-recovery-failure-${Date.now()}.log`;
|
|
72
|
+
const failureLogContent = `=== Claude Recovery Failure Log ${new Date().toISOString()} ===\n` +
|
|
73
|
+
`Original prompt: "${prompt}"\n` +
|
|
74
|
+
`Original error: ${error.message}\n` +
|
|
75
|
+
`Retry error: ${retryError.message}\n` +
|
|
76
|
+
`Retry stack: ${retryError.stack}\n` +
|
|
77
|
+
`=== End Failure Log ===\n\n`;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await import('fs/promises').then(fs => fs.appendFile(failureLogFile, failureLogContent, 'utf8'));
|
|
81
|
+
console.log(`Recovery failure logged to: ${failureLogFile}`);
|
|
82
|
+
} catch (logError) {
|
|
83
|
+
console.error('Failed to log recovery failure:', logError.message);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.error('Recovery attempt failed:', retryError.message);
|
|
87
|
+
throw new Error(`Failed to execute claude command even after interactive recovery. Original error: ${error.message}, Retry error: ${retryError.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Attempts to execute a claude command and parse the JSON output.
|
|
97
|
+
* @param {string} prompt The prompt to send to Claude.
|
|
98
|
+
* @param {string|null} sessionId Session ID to resume, or null for new session.
|
|
99
|
+
* @param {object} options Options object for task configuration.
|
|
100
|
+
* @returns {Promise<object>} The parsed result object.
|
|
101
|
+
*/
|
|
102
|
+
async function attemptClaudeExecution(prompt, sessionId = null, options = {}) {
|
|
103
|
+
const timestamp = new Date().toISOString();
|
|
104
|
+
const logFile = `./.eck/logs/claude-execution-${Date.now()}.log`;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
// Use spawn instead of execa for better control over streaming and timeouts
|
|
108
|
+
const result = await executeClaudeWithDynamicTimeout(prompt, sessionId, options);
|
|
109
|
+
const { stdout, stderr } = result;
|
|
110
|
+
|
|
111
|
+
// Логируем весь вывод в файл
|
|
112
|
+
const commandStr = sessionId ?
|
|
113
|
+
`claude "${prompt}" --resume ${sessionId} -p --output-format=stream-json --verbose` :
|
|
114
|
+
`claude "${prompt}" -p --output-format=stream-json --verbose`;
|
|
115
|
+
const logContent = `=== Claude Execution Log ${timestamp} ===\n` +
|
|
116
|
+
`Command: ${commandStr}\n` +
|
|
117
|
+
`STDOUT:\n${stdout}\n` +
|
|
118
|
+
`STDERR:\n${stderr}\n` +
|
|
119
|
+
`=== End Log ===\n\n`;
|
|
120
|
+
|
|
121
|
+
await import('fs/promises').then(fs => fs.appendFile(logFile, logContent, 'utf8'));
|
|
122
|
+
console.log(`Claude execution logged to: ${logFile}`);
|
|
123
|
+
|
|
124
|
+
if (stderr) {
|
|
125
|
+
console.warn('Warning from claude-code process:', stderr);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const lines = stdout.trim().split('\n');
|
|
129
|
+
|
|
130
|
+
// Find the final result JSON object
|
|
131
|
+
let resultJson = null;
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(line);
|
|
135
|
+
if (parsed.type === 'result') {
|
|
136
|
+
resultJson = parsed;
|
|
137
|
+
}
|
|
138
|
+
} catch (e) {
|
|
139
|
+
// Skip invalid JSON lines
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!resultJson) {
|
|
145
|
+
throw new Error('No result JSON found in claude-code output.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
result: resultJson.result,
|
|
150
|
+
cost: resultJson.total_cost_usd,
|
|
151
|
+
usage: resultJson.usage,
|
|
152
|
+
duration_ms: resultJson.duration_ms
|
|
153
|
+
};
|
|
154
|
+
} catch (error) {
|
|
155
|
+
// Логируем ошибки тоже
|
|
156
|
+
const errorLogContent = `=== Claude Execution Error ${timestamp} ===\n` +
|
|
157
|
+
`Command: claude "${prompt}" -p --output-format=stream-json --verbose\n` +
|
|
158
|
+
`Error: ${error.message}\n` +
|
|
159
|
+
`Stack: ${error.stack}\n` +
|
|
160
|
+
`=== End Error Log ===\n\n`;
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await import('fs/promises').then(fs => fs.appendFile(logFile, errorLogContent, 'utf8'));
|
|
164
|
+
console.log(`Claude execution error logged to: ${logFile}`);
|
|
165
|
+
} catch (logError) {
|
|
166
|
+
console.error('Failed to log error:', logError.message);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Checks if the error is related to Claude session limits.
|
|
175
|
+
* @param {Error} error The error to check.
|
|
176
|
+
* @returns {boolean} True if it's a session limit error.
|
|
177
|
+
*/
|
|
178
|
+
function isSessionLimitError(error) {
|
|
179
|
+
// Don't treat simple timeouts as session limits
|
|
180
|
+
if (error.message.includes('Command timed out after') &&
|
|
181
|
+
!error.message.includes('5-hour') &&
|
|
182
|
+
!error.message.includes('limit')) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const limitPatterns = [
|
|
187
|
+
/approaching 5-hour limit/i,
|
|
188
|
+
/5-hour limit/i,
|
|
189
|
+
/session limit reached/i,
|
|
190
|
+
/daily limit reached/i,
|
|
191
|
+
/usage limit reached/i,
|
|
192
|
+
/rate limit exceeded/i,
|
|
193
|
+
/quota exceeded/i,
|
|
194
|
+
/too many requests/i,
|
|
195
|
+
/maximum session duration/i,
|
|
196
|
+
/session expired/i
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
const errorText = error.message + ' ' + (error.stdout || '') + ' ' + (error.stderr || '');
|
|
200
|
+
return limitPatterns.some(pattern => pattern.test(errorText));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Logs session limit errors with helpful recommendations.
|
|
205
|
+
* @param {Error} error The limit error.
|
|
206
|
+
* @param {string} prompt The original prompt.
|
|
207
|
+
*/
|
|
208
|
+
async function logSessionLimitError(error, prompt) {
|
|
209
|
+
const timestamp = new Date().toISOString();
|
|
210
|
+
const currentTime = new Date();
|
|
211
|
+
const limitLogFile = `./.eck/logs/claude-session-limit-${Date.now()}.log`;
|
|
212
|
+
|
|
213
|
+
// Calculate suggested wait times based on error type
|
|
214
|
+
const limitInfo = analyzeLimitType(error.message);
|
|
215
|
+
const waitMinutes = limitInfo.suggestedWaitMinutes;
|
|
216
|
+
const resumeTime = new Date(currentTime.getTime() + waitMinutes * 60000);
|
|
217
|
+
|
|
218
|
+
const recommendations = [
|
|
219
|
+
"🛑 CLAUDE SESSION LIMIT REACHED",
|
|
220
|
+
"",
|
|
221
|
+
"📋 What happened:",
|
|
222
|
+
`- Error: ${error.message}`,
|
|
223
|
+
`- Prompt: "${prompt}"`,
|
|
224
|
+
`- Time: ${timestamp}`,
|
|
225
|
+
`- Limit type: ${limitInfo.type}`,
|
|
226
|
+
limitInfo.extractedFromMessage ? `- Claude said available again at: ${limitInfo.exactEndTime}` : "",
|
|
227
|
+
"",
|
|
228
|
+
"⏰ Timing information:",
|
|
229
|
+
`- Current time: ${currentTime.toLocaleString()}`,
|
|
230
|
+
`- Suggested wait: ${waitMinutes} minutes`,
|
|
231
|
+
`- Try again after: ${resumeTime.toLocaleString()}`,
|
|
232
|
+
`- Resume at: ${resumeTime.toISOString()}`,
|
|
233
|
+
limitInfo.extractedFromMessage ? "- ✅ Time extracted directly from Claude's message" : "- ⚠️ Time estimated based on limit type",
|
|
234
|
+
"",
|
|
235
|
+
"🔄 Recommended actions:",
|
|
236
|
+
`1. Take a break for at least ${waitMinutes} minutes`,
|
|
237
|
+
"2. Try again after the suggested time above",
|
|
238
|
+
limitInfo.type === '5-hour' ? "3. Consider splitting work into shorter sessions (< 4 hours)" : "3. Monitor usage to avoid hitting limits again",
|
|
239
|
+
"4. Check claude status page for any service issues",
|
|
240
|
+
"",
|
|
241
|
+
"⚡ Prevention tips:",
|
|
242
|
+
"- Use shorter, more focused prompts",
|
|
243
|
+
"- Batch multiple questions efficiently",
|
|
244
|
+
"- Take regular breaks during long coding sessions",
|
|
245
|
+
limitInfo.type === '5-hour' ? "- Set reminders to take breaks every 3-4 hours" : "",
|
|
246
|
+
"",
|
|
247
|
+
"📊 Full error details:"
|
|
248
|
+
].filter(line => line !== ""); // Remove empty strings
|
|
249
|
+
|
|
250
|
+
const limitLogContent = recommendations.join('\n') + '\n' +
|
|
251
|
+
`STDOUT: ${error.stdout || 'N/A'}\n` +
|
|
252
|
+
`STDERR: ${error.stderr || 'N/A'}\n` +
|
|
253
|
+
`Stack: ${error.stack || 'N/A'}\n` +
|
|
254
|
+
`=== End Session Limit Log ===\n\n`;
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
await import('fs/promises').then(fs => fs.appendFile(limitLogFile, limitLogContent, 'utf8'));
|
|
258
|
+
console.log(`🛑 Session limit error logged to: ${limitLogFile}`);
|
|
259
|
+
console.log(`⏰ Recommendation: Take a break and try again later!`);
|
|
260
|
+
} catch (logError) {
|
|
261
|
+
console.error('Failed to log session limit error:', logError.message);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Analyzes the limit error message to determine wait time and type.
|
|
267
|
+
* @param {string} errorMessage The error message to analyze.
|
|
268
|
+
* @returns {{type: string, suggestedWaitMinutes: number}} Limit analysis results.
|
|
269
|
+
*/
|
|
270
|
+
function analyzeLimitType(errorMessage) {
|
|
271
|
+
const message = errorMessage.toLowerCase();
|
|
272
|
+
|
|
273
|
+
// Try to extract exact end time from claude's message
|
|
274
|
+
const timePatterns = [
|
|
275
|
+
/session will end at (\d{1,2}:\d{2})/i,
|
|
276
|
+
/available again at (\d{1,2}:\d{2})/i,
|
|
277
|
+
/try again after (\d{1,2}:\d{2})/i,
|
|
278
|
+
/resume at (\d{1,2}:\d{2})/i,
|
|
279
|
+
/until (\d{1,2}:\d{2})/i
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
for (const pattern of timePatterns) {
|
|
283
|
+
const match = errorMessage.match(pattern);
|
|
284
|
+
if (match) {
|
|
285
|
+
const timeString = match[1];
|
|
286
|
+
const [hours, minutes] = timeString.split(':').map(Number);
|
|
287
|
+
const now = new Date();
|
|
288
|
+
const endTime = new Date();
|
|
289
|
+
endTime.setHours(hours, minutes, 0, 0);
|
|
290
|
+
|
|
291
|
+
// If end time is earlier than now, assume it's tomorrow
|
|
292
|
+
if (endTime <= now) {
|
|
293
|
+
endTime.setDate(endTime.getDate() + 1);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const waitMinutes = Math.ceil((endTime - now) / (1000 * 60));
|
|
297
|
+
return {
|
|
298
|
+
type: 'exact-time',
|
|
299
|
+
suggestedWaitMinutes: Math.max(waitMinutes, 5), // At least 5 minutes
|
|
300
|
+
exactEndTime: endTime.toLocaleString(),
|
|
301
|
+
extractedFromMessage: true
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (message.includes('approaching 5-hour') || message.includes('5-hour limit')) {
|
|
307
|
+
// 5-hour limit - suggest waiting 1 hour (limits usually reset within 1-2 hours)
|
|
308
|
+
return {
|
|
309
|
+
type: '5-hour',
|
|
310
|
+
suggestedWaitMinutes: 60
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (message.includes('daily limit') || message.includes('24-hour')) {
|
|
315
|
+
// Daily limit - suggest waiting until next day
|
|
316
|
+
const now = new Date();
|
|
317
|
+
const tomorrow = new Date(now);
|
|
318
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
319
|
+
tomorrow.setHours(0, 0, 0, 0); // Start of next day
|
|
320
|
+
const minutesUntilMidnight = Math.ceil((tomorrow - now) / (1000 * 60));
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
type: 'daily',
|
|
324
|
+
suggestedWaitMinutes: Math.min(minutesUntilMidnight, 24 * 60) // Max 24 hours
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (message.includes('rate limit') || message.includes('too many requests')) {
|
|
329
|
+
// Rate limit - usually short, suggest 15-30 minutes
|
|
330
|
+
return {
|
|
331
|
+
type: 'rate-limit',
|
|
332
|
+
suggestedWaitMinutes: 30
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (message.includes('quota exceeded')) {
|
|
337
|
+
// Quota limit - could be monthly, suggest checking billing/usage
|
|
338
|
+
return {
|
|
339
|
+
type: 'quota',
|
|
340
|
+
suggestedWaitMinutes: 60
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Default for unknown limit types
|
|
345
|
+
return {
|
|
346
|
+
type: 'unknown',
|
|
347
|
+
suggestedWaitMinutes: 45
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Extracts the last session_id from recent logs.
|
|
353
|
+
* @returns {Promise<string|null>} The last session_id or null if not found.
|
|
354
|
+
*/
|
|
355
|
+
async function getLastSessionId() {
|
|
356
|
+
try {
|
|
357
|
+
const fs = await import('fs/promises');
|
|
358
|
+
const path = await import('path');
|
|
359
|
+
|
|
360
|
+
// Get all log files sorted by modification time (newest first)
|
|
361
|
+
const logFiles = await fs.readdir('./.eck/logs');
|
|
362
|
+
const executionLogs = logFiles
|
|
363
|
+
.filter(file => file.startsWith('claude-execution-') && file.endsWith('.log'))
|
|
364
|
+
.map(file => ({
|
|
365
|
+
name: file,
|
|
366
|
+
path: `./.eck/logs/${file}`,
|
|
367
|
+
time: parseInt(file.match(/claude-execution-(\d+)\.log/)?.[1] || '0')
|
|
368
|
+
}))
|
|
369
|
+
.sort((a, b) => b.time - a.time);
|
|
370
|
+
|
|
371
|
+
// Read the most recent log file
|
|
372
|
+
if (executionLogs.length > 0) {
|
|
373
|
+
const content = await fs.readFile(executionLogs[0].path, 'utf8');
|
|
374
|
+
|
|
375
|
+
// Extract session_id from the log content
|
|
376
|
+
const sessionMatch = content.match(/"session_id":"([^"]+)"/);
|
|
377
|
+
if (sessionMatch) {
|
|
378
|
+
return sessionMatch[1];
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return null;
|
|
383
|
+
} catch (error) {
|
|
384
|
+
console.warn('Failed to extract session_id from logs:', error.message);
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Executes a prompt with a specific session ID.
|
|
391
|
+
* @param {string} prompt The prompt to send to Claude.
|
|
392
|
+
* @param {string} sessionId The specific session ID to resume.
|
|
393
|
+
* @param {object} options Options object for task configuration.
|
|
394
|
+
* @returns {Promise<object>} A promise that resolves with the final JSON output object from Claude.
|
|
395
|
+
*/
|
|
396
|
+
export async function executePromptWithSession(prompt, sessionId, options = {}) {
|
|
397
|
+
console.log(`Resuming conversation with session: ${sessionId}`);
|
|
398
|
+
return await attemptClaudeExecution(prompt, sessionId, options);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Executes claude with dynamic timeout that extends when output is detected.
|
|
403
|
+
* @param {string} prompt The prompt to send to Claude.
|
|
404
|
+
* @param {string|null} sessionId Session ID to resume, or null for new session.
|
|
405
|
+
* @param {object} options Options object with taskSize for calculating dynamic timeout.
|
|
406
|
+
* @returns {Promise<{stdout: string, stderr: string}>} The execution result.
|
|
407
|
+
*/
|
|
408
|
+
async function executeClaudeWithDynamicTimeout(prompt, sessionId = null, options = {}) {
|
|
409
|
+
return new Promise((resolve, reject) => {
|
|
410
|
+
|
|
411
|
+
const args = [];
|
|
412
|
+
if (sessionId) {
|
|
413
|
+
args.push('--resume', sessionId);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Always add the skip permissions flag for automation reliability
|
|
417
|
+
args.push('--dangerously-skip-permissions');
|
|
418
|
+
|
|
419
|
+
args.push(prompt, '-p', '--output-format=stream-json', '--verbose');
|
|
420
|
+
|
|
421
|
+
const child = spawn('claude', args, {
|
|
422
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
let stdout = '';
|
|
426
|
+
let stderr = '';
|
|
427
|
+
let lastOutputTime = Date.now();
|
|
428
|
+
let isFinished = false;
|
|
429
|
+
|
|
430
|
+
// Dynamic timeout calculation based on task size
|
|
431
|
+
const taskSize = options.taskSize || 0;
|
|
432
|
+
const BASE_TIMEOUT = 60000; // 60 seconds base
|
|
433
|
+
const PER_ITEM_TIMEOUT = 200; // 200ms per file/item
|
|
434
|
+
const ACTIVITY_TIMEOUT = BASE_TIMEOUT + (taskSize * PER_ITEM_TIMEOUT);
|
|
435
|
+
|
|
436
|
+
console.log(`⏱️ Using dynamic activity timeout: ${(ACTIVITY_TIMEOUT / 1000).toFixed(1)}s for ${taskSize} items`);
|
|
437
|
+
|
|
438
|
+
const INITIAL_TIMEOUT = 30000; // 30 seconds initial
|
|
439
|
+
const MAX_TOTAL_TIME = 20 * 60000; // 20 minutes maximum
|
|
440
|
+
|
|
441
|
+
// Reset timeout whenever we see new output
|
|
442
|
+
const resetTimeout = () => {
|
|
443
|
+
lastOutputTime = Date.now();
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// Monitor for activity and kill if inactive too long
|
|
447
|
+
const activityChecker = setInterval(() => {
|
|
448
|
+
if (isFinished) return;
|
|
449
|
+
|
|
450
|
+
const timeSinceLastOutput = Date.now() - lastOutputTime;
|
|
451
|
+
const totalTime = Date.now() - lastOutputTime + timeSinceLastOutput;
|
|
452
|
+
|
|
453
|
+
if (totalTime > MAX_TOTAL_TIME) {
|
|
454
|
+
console.log('⏰ Maximum execution time reached (20 minutes)');
|
|
455
|
+
child.kill('SIGTERM');
|
|
456
|
+
clearInterval(activityChecker);
|
|
457
|
+
reject(new Error('Maximum execution time exceeded (20 minutes)'));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (timeSinceLastOutput > ACTIVITY_TIMEOUT) {
|
|
462
|
+
console.log(`💀 No activity detected for ${(ACTIVITY_TIMEOUT/1000).toFixed(1)}s, killing process`);
|
|
463
|
+
child.kill('SIGTERM');
|
|
464
|
+
clearInterval(activityChecker);
|
|
465
|
+
reject(new Error(`No output received for ${(ACTIVITY_TIMEOUT/1000).toFixed(1)} seconds`));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Show activity indicators we're looking for
|
|
470
|
+
if (stdout.includes('✻') || stdout.includes('🔍') || stdout.includes('⚙️') ||
|
|
471
|
+
stdout.includes('Forging') || stdout.includes('Processing') || stdout.includes('Searching')) {
|
|
472
|
+
console.log('✨ Claude is active, extending timeout...');
|
|
473
|
+
resetTimeout();
|
|
474
|
+
}
|
|
475
|
+
}, 5000); // Check every 5 seconds
|
|
476
|
+
|
|
477
|
+
child.stdout.on('data', (data) => {
|
|
478
|
+
stdout += data.toString();
|
|
479
|
+
resetTimeout();
|
|
480
|
+
|
|
481
|
+
// Log interesting activity
|
|
482
|
+
const newData = data.toString();
|
|
483
|
+
if (newData.includes('✻') || newData.includes('Forging') || newData.includes('Processing')) {
|
|
484
|
+
console.log('🔄 Activity detected:', newData.trim().substring(0, 50) + '...');
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
child.stderr.on('data', (data) => {
|
|
489
|
+
stderr += data.toString();
|
|
490
|
+
resetTimeout();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
child.on('close', (code) => {
|
|
494
|
+
isFinished = true;
|
|
495
|
+
clearInterval(activityChecker);
|
|
496
|
+
|
|
497
|
+
if (code === 0) {
|
|
498
|
+
resolve({ stdout, stderr });
|
|
499
|
+
} else {
|
|
500
|
+
reject(new Error(`Claude process exited with code ${code}`));
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
child.on('error', (error) => {
|
|
505
|
+
isFinished = true;
|
|
506
|
+
clearInterval(activityChecker);
|
|
507
|
+
reject(error);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// Initial timeout
|
|
511
|
+
setTimeout(() => {
|
|
512
|
+
if (!isFinished && stdout.length === 0) {
|
|
513
|
+
console.log('⏰ Initial timeout - no output received');
|
|
514
|
+
child.kill('SIGTERM');
|
|
515
|
+
clearInterval(activityChecker);
|
|
516
|
+
reject(new Error('Initial timeout - no response from claude'));
|
|
517
|
+
}
|
|
518
|
+
}, INITIAL_TIMEOUT);
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Executes a prompt using gemini-cli delegation with retry logic for transient errors.
|
|
524
|
+
* @param {string} prompt The prompt to send to Claude via gemini-cli.
|
|
525
|
+
* @returns {Promise<object>} A promise that resolves with the response from Claude.
|
|
526
|
+
*/
|
|
527
|
+
export async function askClaude(prompt) {
|
|
528
|
+
return pRetry(async () => {
|
|
529
|
+
try {
|
|
530
|
+
const result = await execa('gemini-cli', ['claude', prompt], {
|
|
531
|
+
timeout: 120000 // 2 minute timeout
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// Parse mcp_feedback if present in prompt
|
|
535
|
+
let mcpFeedback = null;
|
|
536
|
+
try {
|
|
537
|
+
const promptObj = JSON.parse(prompt);
|
|
538
|
+
if (promptObj.payload && promptObj.payload.post_execution_steps && promptObj.payload.post_execution_steps.mcp_feedback) {
|
|
539
|
+
mcpFeedback = promptObj.payload.post_execution_steps.mcp_feedback;
|
|
540
|
+
|
|
541
|
+
// Log if errors array is non-empty
|
|
542
|
+
if (mcpFeedback.errors && Array.isArray(mcpFeedback.errors) && mcpFeedback.errors.length > 0) {
|
|
543
|
+
console.warn('MCP feedback contains errors:', mcpFeedback.errors);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
} catch (parseError) {
|
|
547
|
+
// If prompt is not valid JSON or doesn't contain mcp_feedback, continue normally
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
stdout: result.stdout,
|
|
552
|
+
stderr: result.stderr,
|
|
553
|
+
success: true,
|
|
554
|
+
mcp_feedback: mcpFeedback
|
|
555
|
+
};
|
|
556
|
+
} catch (error) {
|
|
557
|
+
// Check if this is a transient error that should be retried
|
|
558
|
+
if (isTransientError(error)) {
|
|
559
|
+
console.log(`Transient error detected, retrying: ${error.message}`);
|
|
560
|
+
throw error; // This will trigger a retry
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Non-transient errors should not be retried
|
|
564
|
+
console.error(`Non-transient error in askClaude: ${error.message}`);
|
|
565
|
+
return {
|
|
566
|
+
stdout: error.stdout || '',
|
|
567
|
+
stderr: error.stderr || error.message,
|
|
568
|
+
success: false,
|
|
569
|
+
error: error.message
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
}, {
|
|
573
|
+
retries: 3,
|
|
574
|
+
minTimeout: 1000,
|
|
575
|
+
maxTimeout: 5000,
|
|
576
|
+
onFailedAttempt: (error) => {
|
|
577
|
+
console.log(`Attempt ${error.attemptNumber} failed. ${error.retriesLeft} retries left.`);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Checks if an error is transient and should be retried.
|
|
584
|
+
* @param {Error} error The error to check.
|
|
585
|
+
* @returns {boolean} True if the error is transient.
|
|
586
|
+
*/
|
|
587
|
+
export function isTransientError(error) {
|
|
588
|
+
const errorMessage = (error.message || '').toLowerCase();
|
|
589
|
+
const stderr = (error.stderr || '').toLowerCase();
|
|
590
|
+
const stdout = (error.stdout || '').toLowerCase();
|
|
591
|
+
const allOutput = `${errorMessage} ${stderr} ${stdout}`;
|
|
592
|
+
|
|
593
|
+
// Network-related errors
|
|
594
|
+
const networkErrors = [
|
|
595
|
+
'network',
|
|
596
|
+
'timeout',
|
|
597
|
+
'connection',
|
|
598
|
+
'econnreset',
|
|
599
|
+
'enotfound',
|
|
600
|
+
'econnrefused',
|
|
601
|
+
'socket hang up'
|
|
602
|
+
];
|
|
603
|
+
|
|
604
|
+
// Quota/rate limit errors
|
|
605
|
+
const quotaErrors = [
|
|
606
|
+
'quota exceeded',
|
|
607
|
+
'rate limit',
|
|
608
|
+
'too many requests',
|
|
609
|
+
'service unavailable',
|
|
610
|
+
'temporarily unavailable',
|
|
611
|
+
'429',
|
|
612
|
+
'500',
|
|
613
|
+
'502',
|
|
614
|
+
'503',
|
|
615
|
+
'504'
|
|
616
|
+
];
|
|
617
|
+
|
|
618
|
+
const transientPatterns = [...networkErrors, ...quotaErrors];
|
|
619
|
+
|
|
620
|
+
return transientPatterns.some(pattern => allOutput.includes(pattern));
|
|
621
|
+
}
|