using System.Diagnostics; using System.Text; using ClaudeDo.Data.Environment; using ClaudeDo.Worker.Config; namespace ClaudeDo.Worker.Runner; public sealed class ClaudeProcess : IClaudeProcess { public const string NoResultPrefix = "Claude exited with code"; private readonly WorkerConfig _cfg; private readonly ILogger _logger; public ClaudeProcess(WorkerConfig cfg, ILogger logger) { _cfg = cfg; _logger = logger; } public async Task RunAsync( IReadOnlyList arguments, string prompt, string workingDirectory, Func onStdoutLine, CancellationToken ct) { var resolved = ExecutableResolver.Resolve(_cfg.ClaudeBin) ?? throw new InvalidOperationException($"'{_cfg.ClaudeBin}' not found on PATH."); var psi = new ProcessStartInfo { WorkingDirectory = workingDirectory, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; // A shim (.cmd/.bat) can't be launched directly with UseShellExecute=false; // route it through cmd.exe /c so the real interpreter starts the target. if (resolved.IsShim) { psi.FileName = "cmd.exe"; psi.ArgumentList.Add("/c"); psi.ArgumentList.Add(resolved.Path); } else { psi.FileName = resolved.Path; } foreach (var arg in arguments) psi.ArgumentList.Add(arg); // Claude Code caps HTTP MCP tool calls at 60 s unless MCP_TOOL_TIMEOUT is raised. // The in-task AskUser tool blocks up to 3 min waiting for the user, so lift the cap // (with margin) or that wait would be killed early. Harmless for every other tool. psi.Environment["MCP_TOOL_TIMEOUT"] = "200000"; using var process = new Process { StartInfo = psi }; process.Start(); ProcessJobObject.Assign(process, _logger); await process.StandardInput.WriteAsync(prompt); process.StandardInput.Close(); var analyzer = new StreamAnalyzer(); var lastStderr = new StringBuilder(); // On cancellation: kill the tree. Killing closes the redirected pipes, // which unblocks the ReadLineAsync loops below (which run without ct // so they reliably drain instead of hanging on cancellation). await using var ctr = ct.Register(() => { try { process.Kill(entireProcessTree: true); } catch { /* already exited */ } }); var stdoutTask = Task.Run(async () => { while (await process.StandardOutput.ReadLineAsync() is { } line) { if (string.IsNullOrEmpty(line)) continue; await onStdoutLine(line); analyzer.ProcessLine(line); } }); var stderrTask = Task.Run(async () => { while (await process.StandardError.ReadLineAsync() is { } line) { if (string.IsNullOrEmpty(line)) continue; lastStderr.AppendLine(line); await onStdoutLine($"[stderr] {line}"); } }); await Task.WhenAll(stdoutTask, stderrTask); await process.WaitForExitAsync(CancellationToken.None); // If we were asked to cancel, surface that to the caller now that // the process is fully reaped. ct.ThrowIfCancellationRequested(); var exitCode = process.ExitCode; var streamResult = analyzer.GetResult(); if (exitCode == 0 && streamResult.ResultMarkdown is not null) { return new RunResult { ExitCode = exitCode, ResultMarkdown = streamResult.ResultMarkdown, StructuredOutputJson = streamResult.StructuredOutputJson, SessionId = streamResult.SessionId, TurnCount = streamResult.TurnCount, TokensIn = streamResult.TokensIn, TokensOut = streamResult.TokensOut, Blocks = streamResult.Blocks, ResultSubtype = streamResult.ResultSubtype, TerminalReason = streamResult.TerminalReason, Errors = streamResult.Errors, }; } var error = lastStderr.Length > 0 ? lastStderr.ToString().Trim() : $"{NoResultPrefix} {exitCode} and no result."; return new RunResult { ExitCode = exitCode, // Kept even on failure: a terminal reason like api_error often carries the // provider's own message (e.g. session-limit + reset time) in this field, // with nothing useful on stderr. ResultMarkdown = streamResult.ResultMarkdown, ErrorMarkdown = error, SessionId = streamResult.SessionId, TurnCount = streamResult.TurnCount, TokensIn = streamResult.TokensIn, TokensOut = streamResult.TokensOut, Blocks = streamResult.Blocks, ResultSubtype = streamResult.ResultSubtype, TerminalReason = streamResult.TerminalReason, Errors = streamResult.Errors, }; } }