UseShellExecute=false only appends .exe when searching PATH, so an npm-installed claude.cmd was never found even though it works from a shell. Adds a shared ExecutableResolver in ClaudeDo.Data (PATH/PATHEXT aware, with known npm/claude install-dir fallbacks) and wires it into ClaudeCliPreflight and ClaudeProcess; shims are launched via cmd.exe /c.
154 lines
5.4 KiB
C#
154 lines
5.4 KiB
C#
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<ClaudeProcess> _logger;
|
|
|
|
public ClaudeProcess(WorkerConfig cfg, ILogger<ClaudeProcess> logger)
|
|
{
|
|
_cfg = cfg;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<RunResult> RunAsync(
|
|
IReadOnlyList<string> arguments,
|
|
string prompt,
|
|
string workingDirectory,
|
|
Func<string, Task> 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,
|
|
};
|
|
}
|
|
}
|