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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +314 -0
  3. package/bin/nightytidy.js +3 -0
  4. package/package.json +55 -0
  5. package/src/checks.js +367 -0
  6. package/src/claude.js +655 -0
  7. package/src/cli.js +1012 -0
  8. package/src/consolidation.js +81 -0
  9. package/src/dashboard-html.js +496 -0
  10. package/src/dashboard-standalone.js +167 -0
  11. package/src/dashboard-tui.js +208 -0
  12. package/src/dashboard.js +427 -0
  13. package/src/env.js +100 -0
  14. package/src/executor.js +550 -0
  15. package/src/git.js +348 -0
  16. package/src/lock.js +186 -0
  17. package/src/logger.js +111 -0
  18. package/src/notifications.js +33 -0
  19. package/src/orchestrator.js +919 -0
  20. package/src/prompts/loader.js +55 -0
  21. package/src/prompts/manifest.json +138 -0
  22. package/src/prompts/specials/changelog.md +28 -0
  23. package/src/prompts/specials/consolidation.md +61 -0
  24. package/src/prompts/specials/doc-update.md +1 -0
  25. package/src/prompts/specials/report.md +95 -0
  26. package/src/prompts/steps/01-documentation.md +173 -0
  27. package/src/prompts/steps/02-test-coverage.md +181 -0
  28. package/src/prompts/steps/03-test-hardening.md +181 -0
  29. package/src/prompts/steps/04-test-architecture.md +130 -0
  30. package/src/prompts/steps/05-test-consolidation.md +165 -0
  31. package/src/prompts/steps/06-test-quality.md +211 -0
  32. package/src/prompts/steps/07-api-design.md +165 -0
  33. package/src/prompts/steps/08-security-sweep.md +207 -0
  34. package/src/prompts/steps/09-dependency-health.md +217 -0
  35. package/src/prompts/steps/10-codebase-cleanup.md +189 -0
  36. package/src/prompts/steps/11-crosscutting-concerns.md +196 -0
  37. package/src/prompts/steps/12-file-decomposition.md +263 -0
  38. package/src/prompts/steps/13-code-elegance.md +329 -0
  39. package/src/prompts/steps/14-architectural-complexity.md +297 -0
  40. package/src/prompts/steps/15-type-safety.md +192 -0
  41. package/src/prompts/steps/16-logging-error-message.md +173 -0
  42. package/src/prompts/steps/17-data-integrity.md +139 -0
  43. package/src/prompts/steps/18-performance.md +183 -0
  44. package/src/prompts/steps/19-cost-resource-optimization.md +136 -0
  45. package/src/prompts/steps/20-error-recovery.md +145 -0
  46. package/src/prompts/steps/21-race-condition-audit.md +178 -0
  47. package/src/prompts/steps/22-bug-hunt.md +229 -0
  48. package/src/prompts/steps/23-frontend-quality.md +210 -0
  49. package/src/prompts/steps/24-uiux-audit.md +284 -0
  50. package/src/prompts/steps/25-state-management.md +170 -0
  51. package/src/prompts/steps/26-perceived-performance.md +190 -0
  52. package/src/prompts/steps/27-devops.md +165 -0
  53. package/src/prompts/steps/28-scheduled-job-chron-jobs.md +141 -0
  54. package/src/prompts/steps/29-observability.md +152 -0
  55. package/src/prompts/steps/30-backup-check.md +155 -0
  56. package/src/prompts/steps/31-product-polish-ux-friction.md +122 -0
  57. package/src/prompts/steps/32-feature-discovery-opportunity.md +128 -0
  58. package/src/prompts/steps/33-strategic-opportunities.md +217 -0
  59. package/src/report.js +540 -0
  60. package/src/setup.js +133 -0
  61. 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
+ }