nightytidy 0.1.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 +314 -0
- package/bin/nightytidy.js +3 -0
- package/package.json +55 -0
- package/src/checks.js +367 -0
- package/src/claude.js +655 -0
- package/src/cli.js +1012 -0
- package/src/consolidation.js +81 -0
- package/src/dashboard-html.js +496 -0
- package/src/dashboard-standalone.js +167 -0
- package/src/dashboard-tui.js +208 -0
- package/src/dashboard.js +427 -0
- package/src/env.js +100 -0
- package/src/executor.js +550 -0
- package/src/git.js +348 -0
- package/src/lock.js +186 -0
- package/src/logger.js +111 -0
- package/src/notifications.js +33 -0
- package/src/orchestrator.js +919 -0
- package/src/prompts/loader.js +55 -0
- package/src/prompts/manifest.json +138 -0
- package/src/prompts/specials/changelog.md +28 -0
- package/src/prompts/specials/consolidation.md +61 -0
- package/src/prompts/specials/doc-update.md +1 -0
- package/src/prompts/specials/report.md +95 -0
- package/src/prompts/steps/01-documentation.md +173 -0
- package/src/prompts/steps/02-test-coverage.md +181 -0
- package/src/prompts/steps/03-test-hardening.md +181 -0
- package/src/prompts/steps/04-test-architecture.md +130 -0
- package/src/prompts/steps/05-test-consolidation.md +165 -0
- package/src/prompts/steps/06-test-quality.md +211 -0
- package/src/prompts/steps/07-api-design.md +165 -0
- package/src/prompts/steps/08-security-sweep.md +207 -0
- package/src/prompts/steps/09-dependency-health.md +217 -0
- package/src/prompts/steps/10-codebase-cleanup.md +189 -0
- package/src/prompts/steps/11-crosscutting-concerns.md +196 -0
- package/src/prompts/steps/12-file-decomposition.md +263 -0
- package/src/prompts/steps/13-code-elegance.md +329 -0
- package/src/prompts/steps/14-architectural-complexity.md +297 -0
- package/src/prompts/steps/15-type-safety.md +192 -0
- package/src/prompts/steps/16-logging-error-message.md +173 -0
- package/src/prompts/steps/17-data-integrity.md +139 -0
- package/src/prompts/steps/18-performance.md +183 -0
- package/src/prompts/steps/19-cost-resource-optimization.md +136 -0
- package/src/prompts/steps/20-error-recovery.md +145 -0
- package/src/prompts/steps/21-race-condition-audit.md +178 -0
- package/src/prompts/steps/22-bug-hunt.md +229 -0
- package/src/prompts/steps/23-frontend-quality.md +210 -0
- package/src/prompts/steps/24-uiux-audit.md +284 -0
- package/src/prompts/steps/25-state-management.md +170 -0
- package/src/prompts/steps/26-perceived-performance.md +190 -0
- package/src/prompts/steps/27-devops.md +165 -0
- package/src/prompts/steps/28-scheduled-job-chron-jobs.md +141 -0
- package/src/prompts/steps/29-observability.md +152 -0
- package/src/prompts/steps/30-backup-check.md +155 -0
- package/src/prompts/steps/31-product-polish-ux-friction.md +122 -0
- package/src/prompts/steps/32-feature-discovery-opportunity.md +128 -0
- package/src/prompts/steps/33-strategic-opportunities.md +217 -0
- package/src/report.js +540 -0
- package/src/setup.js +133 -0
- package/src/sync.js +536 -0
package/src/claude.js
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { spawn, execFileSync } from 'child_process';
|
|
2
|
+
import { platform } from 'os';
|
|
3
|
+
import { info, debug, warn, error as logError } from './logger.js';
|
|
4
|
+
import { cleanEnv } from './env.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @fileoverview Claude Code subprocess wrapper.
|
|
8
|
+
*
|
|
9
|
+
* Spawns Claude Code CLI as a subprocess with retry logic, timeout handling,
|
|
10
|
+
* and error classification. This module never throws — it always returns
|
|
11
|
+
* result objects.
|
|
12
|
+
*
|
|
13
|
+
* Error contract: Returns { success, output, error, exitCode, duration, attempts, cost, errorType, retryAfterMs }
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @typedef {'rate_limit' | 'unknown'} ErrorType */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} CostData
|
|
20
|
+
* @property {number|null} costUSD - Total cost in USD
|
|
21
|
+
* @property {number|null} inputTokens - Total input tokens (including cache)
|
|
22
|
+
* @property {number|null} outputTokens - Total output tokens
|
|
23
|
+
* @property {number|null} numTurns - Number of conversation turns
|
|
24
|
+
* @property {number|null} durationApiMs - API call duration in milliseconds
|
|
25
|
+
* @property {string|null} sessionId - Claude session ID for --continue
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} RunPromptResult
|
|
30
|
+
* @property {boolean} success - Whether the prompt completed successfully
|
|
31
|
+
* @property {string} output - Claude's response text (empty string if failed)
|
|
32
|
+
* @property {string|null} error - Error message if failed, null if success
|
|
33
|
+
* @property {number} exitCode - Process exit code (-1 for internal errors)
|
|
34
|
+
* @property {number} duration - Total duration in milliseconds
|
|
35
|
+
* @property {number} attempts - Number of attempts made (1 + retries)
|
|
36
|
+
* @property {CostData|null} cost - Cost and token usage data
|
|
37
|
+
* @property {ErrorType} [errorType] - Type of error (rate_limit or unknown)
|
|
38
|
+
* @property {number|null} [retryAfterMs] - Suggested retry delay for rate limits
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {Object} RunPromptOptions
|
|
43
|
+
* @property {number} [timeout] - Timeout per attempt in milliseconds (default: 45 min)
|
|
44
|
+
* @property {number} [retries] - Number of retry attempts (default: 3)
|
|
45
|
+
* @property {string} [label] - Human-readable label for logging
|
|
46
|
+
* @property {AbortSignal} [signal] - Abort signal for cancellation
|
|
47
|
+
* @property {boolean} [continueSession] - Use --continue flag for session continuity
|
|
48
|
+
* @property {(chunk: string) => void} [onOutput] - Callback for streaming output
|
|
49
|
+
* @property {number} [inactivityTimeout] - Max silence per attempt in ms (default: 5 min; 0 disables)
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const DEFAULT_TIMEOUT = 45 * 60 * 1000; // 45 minutes
|
|
53
|
+
const DEFAULT_RETRIES = 3;
|
|
54
|
+
const RETRY_DELAY = 10000; // 10 seconds
|
|
55
|
+
const STDIN_THRESHOLD = 8000; // chars
|
|
56
|
+
const SIGKILL_DELAY = 5000; // grace period before SIGKILL after initial kill
|
|
57
|
+
export const INACTIVITY_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes — no stdout or stderr data
|
|
58
|
+
|
|
59
|
+
// ── Rate-limit error classification ─────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Error type constants for classification.
|
|
63
|
+
* @type {Readonly<{RATE_LIMIT: 'rate_limit', UNKNOWN: 'unknown'}>}
|
|
64
|
+
*/
|
|
65
|
+
export const ERROR_TYPE = Object.freeze({
|
|
66
|
+
RATE_LIMIT: 'rate_limit',
|
|
67
|
+
UNKNOWN: 'unknown',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const RATE_LIMIT_PATTERNS = [
|
|
71
|
+
/429/i,
|
|
72
|
+
/rate.?limit/i,
|
|
73
|
+
/quota/i,
|
|
74
|
+
/exceeded/i,
|
|
75
|
+
/overloaded/i,
|
|
76
|
+
/capacity/i,
|
|
77
|
+
/too many requests/i,
|
|
78
|
+
/usage.?limit/i,
|
|
79
|
+
/throttl/i,
|
|
80
|
+
/billing/i,
|
|
81
|
+
/plan.?limit/i,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const RETRY_AFTER_PATTERN = /retry.?after[:\s]+(\d+)/i;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @typedef {Object} ErrorClassification
|
|
88
|
+
* @property {ErrorType} type - The error type (rate_limit or unknown)
|
|
89
|
+
* @property {number|null} retryAfterMs - Suggested retry delay in ms, or null
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Classify a Claude Code subprocess error based on stderr content.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} stderr - Stderr output from the subprocess
|
|
96
|
+
* @param {number} exitCode - Process exit code (unused but kept for future use)
|
|
97
|
+
* @returns {ErrorClassification} Classification with type and retry delay
|
|
98
|
+
*/
|
|
99
|
+
export function classifyError(stderr, exitCode) {
|
|
100
|
+
if (!stderr) return { type: ERROR_TYPE.UNKNOWN, retryAfterMs: null };
|
|
101
|
+
const isRateLimit = RATE_LIMIT_PATTERNS.some(p => p.test(stderr));
|
|
102
|
+
if (isRateLimit) {
|
|
103
|
+
const match = stderr.match(RETRY_AFTER_PATTERN);
|
|
104
|
+
const retryAfterMs = match ? parseInt(match[1], 10) * 1000 : null;
|
|
105
|
+
return { type: ERROR_TYPE.RATE_LIMIT, retryAfterMs };
|
|
106
|
+
}
|
|
107
|
+
return { type: ERROR_TYPE.UNKNOWN, retryAfterMs: null };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Force-kill a child process, including its entire process tree.
|
|
112
|
+
*
|
|
113
|
+
* On Windows with shell: true, child.kill() only terminates the cmd.exe
|
|
114
|
+
* shell — the underlying process tree (claude, node, chrome, etc.)
|
|
115
|
+
* survives as orphans. We use `taskkill /F /T` to kill the full tree.
|
|
116
|
+
*
|
|
117
|
+
* @param {import('child_process').ChildProcess} child - The child process to kill
|
|
118
|
+
* @returns {void}
|
|
119
|
+
*/
|
|
120
|
+
function forceKillChild(child) {
|
|
121
|
+
if (platform() === 'win32') {
|
|
122
|
+
try {
|
|
123
|
+
execFileSync('taskkill', ['/F', '/T', '/PID', String(child.pid)], {
|
|
124
|
+
stdio: 'ignore',
|
|
125
|
+
timeout: 5000,
|
|
126
|
+
});
|
|
127
|
+
} catch {
|
|
128
|
+
// taskkill failed (process already dead or permissions issue).
|
|
129
|
+
// Fall back to Node's child.kill() which at least kills cmd.exe.
|
|
130
|
+
try { child.kill(); } catch { /* already dead */ }
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
child.kill();
|
|
134
|
+
const killTimer = setTimeout(() => {
|
|
135
|
+
try { child.kill('SIGKILL'); } catch { /* already dead */ }
|
|
136
|
+
}, SIGKILL_DELAY);
|
|
137
|
+
killTimer.unref();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Generate a human-readable timeout error message.
|
|
143
|
+
*
|
|
144
|
+
* @param {number} ms - Timeout duration in milliseconds
|
|
145
|
+
* @returns {string} Error message
|
|
146
|
+
*/
|
|
147
|
+
function timeoutMessage(ms) {
|
|
148
|
+
const minutes = Math.round(ms / 60000);
|
|
149
|
+
return `Claude Code timed out after ${minutes} minutes`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Generate a human-readable inactivity timeout error message.
|
|
154
|
+
*
|
|
155
|
+
* @param {number} ms - Inactivity timeout duration in milliseconds
|
|
156
|
+
* @returns {string} Error message
|
|
157
|
+
*/
|
|
158
|
+
function inactivityMessage(ms) {
|
|
159
|
+
const minutes = Math.round(ms / 60000);
|
|
160
|
+
return `Claude Code stalled — no output for ${minutes} minutes`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Sleep for a given duration, with optional abort signal support.
|
|
165
|
+
*
|
|
166
|
+
* @param {number} ms - Duration to sleep in milliseconds
|
|
167
|
+
* @param {AbortSignal} [signal] - Optional abort signal to cancel sleep early
|
|
168
|
+
* @returns {Promise<void>} Resolves when sleep completes or is aborted
|
|
169
|
+
*/
|
|
170
|
+
export function sleep(ms, signal) {
|
|
171
|
+
return new Promise((resolve) => {
|
|
172
|
+
if (signal?.aborted) { resolve(); return; }
|
|
173
|
+
const timer = setTimeout(resolve, ms);
|
|
174
|
+
const onAbort = () => { clearTimeout(timer); resolve(); };
|
|
175
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Spawn a Claude Code subprocess with the given prompt.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} prompt - The prompt to send to Claude
|
|
183
|
+
* @param {string} cwd - Working directory for the subprocess
|
|
184
|
+
* @param {boolean} [useShell=false] - Whether to spawn with shell (required on Windows)
|
|
185
|
+
* @param {boolean} [continueSession=false] - Whether to use --continue flag
|
|
186
|
+
* @returns {import('child_process').ChildProcess} The spawned child process
|
|
187
|
+
*/
|
|
188
|
+
function spawnClaude(prompt, cwd, useShell = false, continueSession = false) {
|
|
189
|
+
const useStdin = prompt.length > STDIN_THRESHOLD;
|
|
190
|
+
|
|
191
|
+
// --dangerously-skip-permissions: required for non-interactive mode.
|
|
192
|
+
// Without it, Claude Code blocks on tool permission prompts (Bash, Edit, etc.)
|
|
193
|
+
// that cannot be approved without a TTY. NightyTidy is the permission layer —
|
|
194
|
+
// it controls what prompts are sent and operates on a safety branch.
|
|
195
|
+
// --output-format stream-json: streams NDJSON events in real-time as the
|
|
196
|
+
// conversation progresses. Each line is a JSON object (assistant message,
|
|
197
|
+
// tool use, etc.). The final line is a `result` event with total_cost_usd,
|
|
198
|
+
// num_turns, duration_api_ms, and the response text in the `result` field.
|
|
199
|
+
const args = useStdin
|
|
200
|
+
? ['--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions']
|
|
201
|
+
: ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions'];
|
|
202
|
+
if (continueSession) args.push('--continue');
|
|
203
|
+
const stdinMode = useStdin ? 'pipe' : 'ignore';
|
|
204
|
+
|
|
205
|
+
debug(`Spawn mode: ${useStdin ? 'stdin' : '-p flag'}, prompt length: ${prompt.length} chars`);
|
|
206
|
+
|
|
207
|
+
const child = spawn('claude', args, {
|
|
208
|
+
cwd,
|
|
209
|
+
stdio: [stdinMode, 'pipe', 'pipe'],
|
|
210
|
+
shell: useShell,
|
|
211
|
+
env: cleanEnv(),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
if (useStdin) {
|
|
215
|
+
child.stdin.write(prompt);
|
|
216
|
+
child.stdin.end();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return child;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Set up a timeout that kills the child process and settles the promise.
|
|
224
|
+
*
|
|
225
|
+
* @param {import('child_process').ChildProcess} child - The child process
|
|
226
|
+
* @param {number} timeoutMs - Timeout duration in milliseconds
|
|
227
|
+
* @param {boolean} verbose - Whether to force-kill (verbose) or simple kill
|
|
228
|
+
* @param {(result: object) => void} settle - Callback to settle the promise
|
|
229
|
+
* @returns {NodeJS.Timeout} The timeout handle (for clearTimeout)
|
|
230
|
+
*/
|
|
231
|
+
function setupTimeout(child, timeoutMs, verbose, settle) {
|
|
232
|
+
return setTimeout(() => {
|
|
233
|
+
forceKillChild(child);
|
|
234
|
+
settle({ success: false, error: timeoutMessage(timeoutMs), exitCode: -1 });
|
|
235
|
+
}, timeoutMs);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Set up an abort signal handler that kills the child process.
|
|
240
|
+
*
|
|
241
|
+
* @param {import('child_process').ChildProcess} child - The child process
|
|
242
|
+
* @param {AbortSignal|undefined} signal - The abort signal (optional)
|
|
243
|
+
* @param {(result: object) => void} settle - Callback to settle the promise
|
|
244
|
+
* @returns {(() => void)|null} The abort handler function, or null if no signal
|
|
245
|
+
*/
|
|
246
|
+
function setupAbortHandler(child, signal, settle) {
|
|
247
|
+
if (!signal) return null;
|
|
248
|
+
const onAbort = () => {
|
|
249
|
+
forceKillChild(child);
|
|
250
|
+
settle({ success: false, error: 'Aborted by user', exitCode: -1 });
|
|
251
|
+
};
|
|
252
|
+
if (signal.aborted) { onAbort(); return onAbort; }
|
|
253
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
254
|
+
return onAbort;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Extract a human-readable summary from a single tool_use content block.
|
|
259
|
+
*
|
|
260
|
+
* @param {Object|null|undefined} input - The tool input object
|
|
261
|
+
* @returns {string} A short summary string (e.g., file path, command snippet)
|
|
262
|
+
*/
|
|
263
|
+
function summarizeToolInput(input) {
|
|
264
|
+
if (!input) return '';
|
|
265
|
+
if (input.file_path) return input.file_path;
|
|
266
|
+
if (input.command) return input.command.length > 80 ? input.command.slice(0, 80) + '...' : input.command;
|
|
267
|
+
if (input.pattern) return input.pattern;
|
|
268
|
+
if (input.query) return input.query.length > 80 ? input.query.slice(0, 80) + '...' : input.query;
|
|
269
|
+
return '';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @typedef {Object} StreamEvent
|
|
274
|
+
* @property {string} type - Event type ('result', 'system', 'user', 'assistant', 'stream_event')
|
|
275
|
+
* @property {Object} [message] - Message content for user/assistant events
|
|
276
|
+
* @property {Object} [event] - Nested event for stream_event type
|
|
277
|
+
* @property {string} [result] - Final result text for result events
|
|
278
|
+
*/
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Convert a parsed stream-json NDJSON event into human-readable display text.
|
|
282
|
+
* Returns null for events that should not be displayed (system, result, etc.).
|
|
283
|
+
*
|
|
284
|
+
* Note: Claude Code CLI v2.1.29 does NOT emit token-level streaming events
|
|
285
|
+
* (`stream_event` type). Output arrives only at turn boundaries as complete
|
|
286
|
+
* `assistant` messages. The `stream_event` handler is kept for forward
|
|
287
|
+
* compatibility in case future CLI versions add token streaming.
|
|
288
|
+
*
|
|
289
|
+
* @param {StreamEvent|null|undefined} event - Parsed NDJSON event
|
|
290
|
+
* @returns {string|null} Display text, or null for non-display events
|
|
291
|
+
*/
|
|
292
|
+
function formatStreamEvent(event) {
|
|
293
|
+
if (!event || !event.type) return null;
|
|
294
|
+
|
|
295
|
+
// Final result — cost data handled by parseJsonOutput, not displayed
|
|
296
|
+
if (event.type === 'result') return null;
|
|
297
|
+
// System init — not useful for display
|
|
298
|
+
if (event.type === 'system') return null;
|
|
299
|
+
|
|
300
|
+
// Tool results — content is too verbose (full file contents, bash output)
|
|
301
|
+
// but emit a brief marker so the output updates when tools complete.
|
|
302
|
+
// Without this, the GUI shows nothing during multi-minute tool executions.
|
|
303
|
+
if (event.type === 'user') {
|
|
304
|
+
const content = event.message?.content;
|
|
305
|
+
if (!Array.isArray(content)) return null;
|
|
306
|
+
const count = content.filter(b => b.type === 'tool_result').length;
|
|
307
|
+
if (count > 0) return ` \u2190 ${count === 1 ? 'result received' : count + ' results received'}\n`;
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Forward compat: token-by-token deltas (not emitted by CLI v2.1.29)
|
|
312
|
+
if (event.type === 'stream_event') {
|
|
313
|
+
const delta = event.event?.delta;
|
|
314
|
+
if (delta?.type === 'text_delta' && delta.text) return delta.text;
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Assistant messages — extract text and tool_use content blocks
|
|
319
|
+
if (event.type === 'assistant') {
|
|
320
|
+
const content = event.message?.content;
|
|
321
|
+
if (!Array.isArray(content)) return null;
|
|
322
|
+
|
|
323
|
+
const parts = [];
|
|
324
|
+
for (const block of content) {
|
|
325
|
+
if (block.type === 'text' && block.text) {
|
|
326
|
+
parts.push(block.text);
|
|
327
|
+
} else if (block.type === 'tool_use' && block.name) {
|
|
328
|
+
const detail = summarizeToolInput(block.input);
|
|
329
|
+
parts.push(`\u25B8 ${block.name}${detail ? ': ' + detail : ''}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return parts.length > 0 ? parts.join('\n') + '\n' : null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Parse a single NDJSON line and return display text (or null).
|
|
340
|
+
* Non-JSON lines are passed through as-is for backward compatibility.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} line - A single line from stdout
|
|
343
|
+
* @returns {string|null} Display text, or null for non-display lines
|
|
344
|
+
*/
|
|
345
|
+
function formatEventLine(line) {
|
|
346
|
+
try {
|
|
347
|
+
const event = JSON.parse(line);
|
|
348
|
+
return formatStreamEvent(event);
|
|
349
|
+
} catch {
|
|
350
|
+
// Not valid JSON — pass through raw text (backward compat with older CLI)
|
|
351
|
+
return line.trim() ? line + '\n' : null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* @typedef {Object} WaitResult
|
|
357
|
+
* @property {boolean} success - Whether the process completed successfully
|
|
358
|
+
* @property {string} output - Stdout content
|
|
359
|
+
* @property {string|null} error - Error message if failed
|
|
360
|
+
* @property {number} exitCode - Process exit code
|
|
361
|
+
* @property {string} [stderr] - Stderr content (if available)
|
|
362
|
+
*/
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Wait for a child process to complete, with timeout and abort handling.
|
|
366
|
+
*
|
|
367
|
+
* @param {import('child_process').ChildProcess} child - The child process
|
|
368
|
+
* @param {number} timeoutMs - Timeout duration in milliseconds
|
|
369
|
+
* @param {Object} [options] - Options
|
|
370
|
+
* @param {boolean} [options.verbose=true] - Whether to log debug output
|
|
371
|
+
* @param {AbortSignal} [options.signal] - Abort signal for cancellation
|
|
372
|
+
* @param {(chunk: string) => void} [options.onOutput] - Callback for streaming output
|
|
373
|
+
* @param {number} [options.inactivityTimeout] - Max silence before kill (default: INACTIVITY_TIMEOUT_MS; 0 disables)
|
|
374
|
+
* @returns {Promise<WaitResult>} Result object with success, output, error, exitCode
|
|
375
|
+
*/
|
|
376
|
+
function waitForChild(child, timeoutMs, { verbose = true, signal, onOutput, inactivityTimeout } = {}) {
|
|
377
|
+
return new Promise((resolve) => {
|
|
378
|
+
// Use array accumulation instead of string concatenation to avoid O(n²) memory
|
|
379
|
+
// allocations on large outputs. Join once at the end when needed.
|
|
380
|
+
const stdoutChunks = [];
|
|
381
|
+
let settled = false;
|
|
382
|
+
let lineBuffer = ''; // Buffers incomplete NDJSON lines for onOutput parsing
|
|
383
|
+
|
|
384
|
+
// Central settle function — guards against double-resolve and cleans up
|
|
385
|
+
const settle = (result) => {
|
|
386
|
+
if (settled) return;
|
|
387
|
+
settled = true;
|
|
388
|
+
clearTimeout(timer);
|
|
389
|
+
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
390
|
+
if (onAbort) signal?.removeEventListener('abort', onAbort);
|
|
391
|
+
const stdout = stdoutChunks.join('');
|
|
392
|
+
resolve({ ...result, output: result.output ?? stdout });
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const timer = setupTimeout(child, timeoutMs, verbose, settle);
|
|
396
|
+
const onAbort = setupAbortHandler(child, signal, settle);
|
|
397
|
+
|
|
398
|
+
// ── Inactivity timer ────────────────────────────────────────────
|
|
399
|
+
// Resets on every stdout or stderr data event. If no data arrives
|
|
400
|
+
// within the inactivity window, the process is presumed stalled
|
|
401
|
+
// and force-killed so the retry loop can attempt recovery.
|
|
402
|
+
const inactivityMs = inactivityTimeout ?? INACTIVITY_TIMEOUT_MS;
|
|
403
|
+
let inactivityTimer = null;
|
|
404
|
+
|
|
405
|
+
function resetInactivityTimer() {
|
|
406
|
+
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
407
|
+
if (inactivityMs > 0) {
|
|
408
|
+
inactivityTimer = setTimeout(() => {
|
|
409
|
+
warn(`Claude Code inactivity timeout (${Math.round(inactivityMs / 60000)} min) — killing stalled process`);
|
|
410
|
+
forceKillChild(child);
|
|
411
|
+
settle({ success: false, error: inactivityMessage(inactivityMs), exitCode: -1 });
|
|
412
|
+
}, inactivityMs);
|
|
413
|
+
inactivityTimer.unref();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
resetInactivityTimer();
|
|
418
|
+
|
|
419
|
+
child.stdout.on('data', (chunk) => {
|
|
420
|
+
resetInactivityTimer();
|
|
421
|
+
const text = chunk.toString();
|
|
422
|
+
stdoutChunks.push(text);
|
|
423
|
+
if (verbose) debug(text.trimEnd());
|
|
424
|
+
if (onOutput) {
|
|
425
|
+
// Parse complete NDJSON lines and extract display text
|
|
426
|
+
lineBuffer += text;
|
|
427
|
+
const lines = lineBuffer.split('\n');
|
|
428
|
+
lineBuffer = lines.pop(); // Keep incomplete last line in buffer
|
|
429
|
+
for (const line of lines) {
|
|
430
|
+
if (!line.trim()) continue;
|
|
431
|
+
const display = formatEventLine(line);
|
|
432
|
+
if (display) {
|
|
433
|
+
try { onOutput(display); } catch { /* callback failure must not crash subprocess */ }
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const stderrChunks = [];
|
|
440
|
+
child.stderr.on('data', (chunk) => {
|
|
441
|
+
resetInactivityTimer();
|
|
442
|
+
const text = chunk.toString();
|
|
443
|
+
stderrChunks.push(text);
|
|
444
|
+
if (verbose && text.trim()) warn(`Claude Code warning output: ${text.trimEnd()}`);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
child.on('error', (err) => {
|
|
448
|
+
settle({ success: false, output: '', error: err.message, exitCode: -1, _errorCode: err.code });
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
child.on('close', (code) => {
|
|
452
|
+
const stdout = stdoutChunks.join('');
|
|
453
|
+
const ok = code === 0 && stdout.trim().length > 0;
|
|
454
|
+
settle({
|
|
455
|
+
success: ok,
|
|
456
|
+
error: ok ? null : (code === 0 ? 'Claude Code returned empty output' : `Claude Code exited with error code ${code}`),
|
|
457
|
+
exitCode: code,
|
|
458
|
+
stderr: stderrChunks.join(''),
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Extract cost metadata from a parsed JSON event.
|
|
466
|
+
* Consolidates the repeated cost object construction logic.
|
|
467
|
+
*
|
|
468
|
+
* @param {Object} json - Parsed JSON from Claude CLI output
|
|
469
|
+
* @param {number} [json.total_cost_usd] - Total cost in USD
|
|
470
|
+
* @param {Object} [json.usage] - Token usage object
|
|
471
|
+
* @param {number} [json.num_turns] - Number of conversation turns
|
|
472
|
+
* @param {number} [json.duration_api_ms] - API duration in milliseconds
|
|
473
|
+
* @param {string} [json.session_id] - Claude session ID
|
|
474
|
+
* @returns {CostData} Normalized cost data object
|
|
475
|
+
*/
|
|
476
|
+
function extractCost(json) {
|
|
477
|
+
const usage = json.usage || {};
|
|
478
|
+
return {
|
|
479
|
+
costUSD: json.total_cost_usd ?? null,
|
|
480
|
+
inputTokens: (usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0) || null,
|
|
481
|
+
outputTokens: usage.output_tokens ?? null,
|
|
482
|
+
numTurns: json.num_turns ?? null,
|
|
483
|
+
durationApiMs: json.duration_api_ms ?? null,
|
|
484
|
+
sessionId: json.session_id ?? null,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Parse Claude Code --output-format stream-json response.
|
|
490
|
+
* Scans NDJSON lines for the final `result` event to extract cost metadata
|
|
491
|
+
* and the response text. Falls back to single-JSON parse (backward compat
|
|
492
|
+
* with --output-format json) and then to raw text (cost: null).
|
|
493
|
+
*
|
|
494
|
+
* @param {WaitResult} result - Raw result from waitForChild
|
|
495
|
+
* @returns {WaitResult & {cost: CostData|null}} Result with parsed output and cost
|
|
496
|
+
*/
|
|
497
|
+
function parseJsonOutput(result) {
|
|
498
|
+
if (!result.output) return { ...result, cost: null };
|
|
499
|
+
|
|
500
|
+
const lines = result.output.trim().split('\n');
|
|
501
|
+
|
|
502
|
+
// Scan backwards for the "result" event (stream-json NDJSON format)
|
|
503
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
504
|
+
try {
|
|
505
|
+
const event = JSON.parse(lines[i]);
|
|
506
|
+
if (event.type === 'result') {
|
|
507
|
+
return {
|
|
508
|
+
...result,
|
|
509
|
+
output: event.result || '',
|
|
510
|
+
cost: extractCost(event),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
} catch {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Fallback: try parsing entire output as single JSON (--output-format json compat)
|
|
519
|
+
try {
|
|
520
|
+
const json = JSON.parse(result.output.trim());
|
|
521
|
+
return {
|
|
522
|
+
...result,
|
|
523
|
+
output: json.result || '',
|
|
524
|
+
cost: extractCost(json),
|
|
525
|
+
};
|
|
526
|
+
} catch {
|
|
527
|
+
// Not valid JSON — CLI may be an old version without JSON output.
|
|
528
|
+
return { ...result, cost: null };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Run a single Claude Code invocation (no retries).
|
|
534
|
+
*
|
|
535
|
+
* @param {string} prompt - The prompt to send
|
|
536
|
+
* @param {string} cwd - Working directory
|
|
537
|
+
* @param {number} timeoutMs - Timeout in milliseconds
|
|
538
|
+
* @param {AbortSignal|undefined} signal - Abort signal
|
|
539
|
+
* @param {boolean} [continueSession=false] - Use --continue flag
|
|
540
|
+
* @param {(chunk: string) => void} [onOutput] - Streaming output callback
|
|
541
|
+
* @returns {Promise<RunPromptResult>} Result with success, output, error, cost, etc.
|
|
542
|
+
*/
|
|
543
|
+
async function runOnce(prompt, cwd, timeoutMs, signal, continueSession = false, onOutput, inactivityTimeout) {
|
|
544
|
+
// On Windows, always use shell — 'claude' is a .cmd script that
|
|
545
|
+
// requires shell interpretation. Spawning without shell always gets
|
|
546
|
+
// ENOENT, and the failed-spawn + shell-retry pattern can exhaust
|
|
547
|
+
// Windows process resources (STATUS_DLL_INIT_FAILED / 0xC0000142).
|
|
548
|
+
const useShell = platform() === 'win32';
|
|
549
|
+
|
|
550
|
+
let child;
|
|
551
|
+
try {
|
|
552
|
+
child = spawnClaude(prompt, cwd, useShell, continueSession);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
return { success: false, output: '', error: err.message || 'Failed to start Claude Code', exitCode: -1, cost: null };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const result = await waitForChild(child, timeoutMs, { signal, onOutput, inactivityTimeout });
|
|
558
|
+
|
|
559
|
+
delete result._errorCode;
|
|
560
|
+
const parsed = parseJsonOutput(result);
|
|
561
|
+
const classification = classifyError(result.stderr || '', result.exitCode);
|
|
562
|
+
return { ...parsed, errorType: classification.type, retryAfterMs: classification.retryAfterMs };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Run a Claude Code prompt with retry logic and error handling.
|
|
567
|
+
*
|
|
568
|
+
* This is the main entry point for running Claude Code. It handles:
|
|
569
|
+
* - Retry logic with configurable attempts
|
|
570
|
+
* - Timeout handling per attempt
|
|
571
|
+
* - Rate-limit detection (skips retries for rate limits)
|
|
572
|
+
* - Abort signal support
|
|
573
|
+
* - Session continuation via --continue flag
|
|
574
|
+
* - Streaming output via onOutput callback
|
|
575
|
+
*
|
|
576
|
+
* Error contract: This function NEVER throws. It always returns a result object.
|
|
577
|
+
*
|
|
578
|
+
* @param {string} prompt - The prompt to send to Claude
|
|
579
|
+
* @param {string} cwd - Working directory for the subprocess
|
|
580
|
+
* @param {RunPromptOptions} [options] - Configuration options
|
|
581
|
+
* @returns {Promise<RunPromptResult>} Result object (always defined, never throws)
|
|
582
|
+
*/
|
|
583
|
+
export async function runPrompt(prompt, cwd, options = {}) {
|
|
584
|
+
const timeoutMs = options.timeout ?? DEFAULT_TIMEOUT;
|
|
585
|
+
const maxRetries = options.retries ?? DEFAULT_RETRIES;
|
|
586
|
+
const label = options.label ?? 'prompt';
|
|
587
|
+
const signal = options.signal;
|
|
588
|
+
const continueSession = options.continueSession ?? false;
|
|
589
|
+
const onOutput = options.onOutput;
|
|
590
|
+
const inactivityTimeout = options.inactivityTimeout;
|
|
591
|
+
const totalAttempts = maxRetries + 1;
|
|
592
|
+
|
|
593
|
+
const startTime = Date.now();
|
|
594
|
+
let lastResult = null;
|
|
595
|
+
|
|
596
|
+
for (let attempt = 1; attempt <= totalAttempts; attempt++) {
|
|
597
|
+
if (signal?.aborted) {
|
|
598
|
+
const duration = Date.now() - startTime;
|
|
599
|
+
return { success: false, output: '', error: 'Aborted by user', exitCode: -1, duration, attempts: attempt, cost: null };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
info(`Running Claude Code: ${label} (attempt ${attempt}/${totalAttempts})`);
|
|
603
|
+
|
|
604
|
+
const result = await runOnce(prompt, cwd, timeoutMs, signal, continueSession, onOutput, inactivityTimeout);
|
|
605
|
+
lastResult = result;
|
|
606
|
+
|
|
607
|
+
// Abort detected — return immediately without retry
|
|
608
|
+
if (signal?.aborted) {
|
|
609
|
+
const duration = Date.now() - startTime;
|
|
610
|
+
return { success: false, output: result.output || '', error: 'Aborted by user', exitCode: -1, duration, attempts: attempt, cost: null };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (result.success) {
|
|
614
|
+
const duration = Date.now() - startTime;
|
|
615
|
+
info(`Claude Code completed: ${label} — ${Math.round(duration / 1000)}s`);
|
|
616
|
+
return { ...result, duration, attempts: attempt };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Rate-limit errors won't resolve in 10s — fail fast so caller can pause
|
|
620
|
+
if (result.errorType === ERROR_TYPE.RATE_LIMIT) {
|
|
621
|
+
const duration = Date.now() - startTime;
|
|
622
|
+
warn(`Claude Code rate limited: ${label} — not retrying (would fail again)`);
|
|
623
|
+
return {
|
|
624
|
+
success: false,
|
|
625
|
+
output: result.output || '',
|
|
626
|
+
error: result.error || 'Rate limit exceeded',
|
|
627
|
+
exitCode: result.exitCode,
|
|
628
|
+
duration,
|
|
629
|
+
attempts: attempt,
|
|
630
|
+
cost: result.cost ?? null,
|
|
631
|
+
errorType: result.errorType,
|
|
632
|
+
retryAfterMs: result.retryAfterMs,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
warn(`Claude Code failed: ${label} — ${result.error} (attempt ${attempt}/${totalAttempts})`);
|
|
637
|
+
|
|
638
|
+
if (attempt < totalAttempts) {
|
|
639
|
+
warn(`Retrying ${label} in 10s (attempt ${attempt + 1}/${totalAttempts})`);
|
|
640
|
+
await sleep(RETRY_DELAY, signal);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const duration = Date.now() - startTime;
|
|
645
|
+
logError(`Claude Code failed: ${label} — all ${totalAttempts} attempts exhausted`);
|
|
646
|
+
return {
|
|
647
|
+
success: false,
|
|
648
|
+
output: lastResult?.output || '',
|
|
649
|
+
error: `Failed after ${totalAttempts} attempts`,
|
|
650
|
+
exitCode: -1,
|
|
651
|
+
duration,
|
|
652
|
+
attempts: totalAttempts,
|
|
653
|
+
cost: lastResult?.cost ?? null,
|
|
654
|
+
};
|
|
655
|
+
}
|