315 lines
17 KiB
C#
315 lines
17 KiB
C#
using System.Runtime.CompilerServices;
|
|
using System.Text;
|
|
using System.Text.Encodings.Web;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Threading.Channels;
|
|
using Volcanic.AgenticCore.Abstractions.Messages;
|
|
using Volcanic.AgenticCore.Abstractions.Providers;
|
|
using Volcanic.AgenticCore.Abstractions.Ui;
|
|
|
|
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
|
|
|
|
/// <summary>Where the CLI, the bridge and the muxer live, plus environment access (overridable for tests).</summary>
|
|
public sealed record ClaudeCodeApiOptions
|
|
{
|
|
/// <summary>The <c>claude</c> executable; null when it was not located (every request then fails with <c>executable_missing</c>).</summary>
|
|
public string? ClaudePath { get; init; }
|
|
|
|
/// <summary>The proposal bridge assembly; null when it was not located (tool-bearing requests then fail with <c>mcp_startup</c>).</summary>
|
|
public string? BridgeDllPath { get; init; }
|
|
|
|
/// <summary>The dotnet muxer that runs the bridge.</summary>
|
|
public string? DotnetPath { get; init; }
|
|
|
|
/// <summary>Environment lookup for the timeout settings.</summary>
|
|
public Func<string, string?> GetEnv { get; init; } = Environment.GetEnvironmentVariable;
|
|
|
|
/// <summary>The environment the allow-list is applied to; the process environment when null.</summary>
|
|
public IReadOnlyDictionary<string, string?>? ParentEnvironment { get; init; }
|
|
|
|
/// <summary>The MCP server name the bridge is registered under (tools become <c>mcp__<name>__<alias></c>).</summary>
|
|
public string ServerName { get; init; } = ToolCatalog.DefaultServerName;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The <c>claude-code-headless</c> API: fully stateless. Every request spawns a fresh <c>claude -p</c>, replays the whole
|
|
/// transcript as one stream-json user message, exposes the active tools through the proposal bridge, captures the
|
|
/// model's <c>tool_use</c> as a proposal (terminating Claude at the stop), and never throws out of <see cref="StreamAsync"/>.
|
|
/// </summary>
|
|
public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions options)
|
|
{
|
|
public const string McpReadyTimeoutVariable = "VOLCANIC_CLAUDE_CODE_MCP_READY_TIMEOUT_MS";
|
|
public static readonly TimeSpan DefaultMcpReadyTimeout = TimeSpan.FromSeconds(5);
|
|
private static readonly TimeSpan ReadyPollInterval = TimeSpan.FromMilliseconds(25);
|
|
private static readonly TimeSpan ExitGrace = TimeSpan.FromSeconds(10);
|
|
private static readonly IReadOnlyDictionary<string, string> NoHeaders = new Dictionary<string, string>();
|
|
private static readonly JsonSerializerOptions StdinOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
|
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
|
|
|
|
public SessionRegistry Registry { get; } = registry ?? throw new ArgumentNullException(nameof(registry));
|
|
public ClaudeCodeApiOptions Options { get; } = options ?? throw new ArgumentNullException(nameof(options));
|
|
|
|
public async IAsyncEnumerable<AssistantMessageEvent> StreamAsync(
|
|
Model model,
|
|
LlmContext context,
|
|
StreamOptions streamOptions,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
{
|
|
var channel = Channel.CreateUnbounded<AssistantMessageEvent>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
|
// Cancelled when the consumer stops enumerating early, so an abandoned request never keeps Claude alive.
|
|
using var consumerGone = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
var worker = RunRequestAsync(model, context, streamOptions ?? StreamOptions.Default, channel.Writer, consumerGone.Token);
|
|
try
|
|
{
|
|
await foreach (var e in channel.Reader.ReadAllAsync(CancellationToken.None).ConfigureAwait(false))
|
|
yield return e;
|
|
}
|
|
finally
|
|
{
|
|
consumerGone.Cancel();
|
|
await worker.ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task RunRequestAsync(Model model, LlmContext context, StreamOptions streamOptions, ChannelWriter<AssistantMessageEvent> writer, CancellationToken ct)
|
|
{
|
|
var builder = new AssistantMessageBuilder(model);
|
|
StreamEventMapper? mapper = null;
|
|
ClaudeProcess? process = null;
|
|
string? tempDir = null;
|
|
string? violationPath = null;
|
|
Action<string, NotifyKind>? notify = null;
|
|
var terminatedForHandoff = false;
|
|
var responseAnnounced = false;
|
|
try
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
ArgumentNullException.ThrowIfNull(model);
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
|
|
var claudePath = Options.ClaudePath;
|
|
if (string.IsNullOrEmpty(claudePath) || !File.Exists(claudePath))
|
|
throw new ClaudeCodeException(ClaudeCodeException.Codes.ExecutableMissing,
|
|
claudePath is null
|
|
? $"Claude Code (claude) was not found on PATH; install it or set {ClaudeInstallation.PathVariable}"
|
|
: $"Claude Code executable was not found: {claudePath}");
|
|
|
|
// Phase 1: resolve the session and prepare the private request state.
|
|
var hasTools = context.Tools.Count > 0;
|
|
var (cwd, images, _) = Registry.Resolve(streamOptions.SessionId, context, hasTools);
|
|
notify = streamOptions.SessionId is null ? null : Registry.Get(streamOptions.SessionId)?.Notify;
|
|
var catalog = new ToolCatalog(context.Tools, Options.ServerName);
|
|
mapper = new StreamEventMapper(model, catalog, builder);
|
|
var effort = ClaudeCodeModels.EffortFor(model, streamOptions.Reasoning);
|
|
var maxOutputTokens = Math.Min(streamOptions.MaxTokens ?? model.MaxTokens, model.MaxTokens);
|
|
if (maxOutputTokens <= 0)
|
|
throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolInit, "maxTokens must be a positive integer");
|
|
|
|
tempDir = Directory.CreateTempSubdirectory("volcanic-claude-").FullName;
|
|
var systemPromptPath = Path.Combine(tempDir, "system-prompt.txt");
|
|
await File.WriteAllTextAsync(systemPromptPath, context.SystemPrompt ?? "", Utf8NoBom, ct).ConfigureAwait(false);
|
|
string? catalogPath = null;
|
|
string? readyPath = null;
|
|
if (!catalog.IsEmpty)
|
|
{
|
|
if (string.IsNullOrEmpty(Options.BridgeDllPath) || !File.Exists(Options.BridgeDllPath))
|
|
throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup,
|
|
$"The proposal MCP bridge ({BridgeLocator.BridgeFileName}) was not found next to the extension; tools cannot be exposed to Claude Code");
|
|
if (string.IsNullOrEmpty(Options.DotnetPath))
|
|
throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, "The dotnet muxer was not located; the proposal MCP bridge cannot be launched");
|
|
catalogPath = Path.Combine(tempDir, "tools.json");
|
|
await File.WriteAllTextAsync(catalogPath, catalog.ToJson() + "\n", Utf8NoBom, ct).ConfigureAwait(false);
|
|
readyPath = Path.Combine(tempDir, "mcp-ready");
|
|
violationPath = Path.Combine(tempDir, "mcp-violation");
|
|
}
|
|
|
|
var stdin = ContextSerializer.BuildStdinMessage(context, catalog, images, cacheBreakpoint: streamOptions.CacheRetention != CacheRetention.None);
|
|
var argv = ClaudeArgs.Build(new ClaudeArgsInput(model.Id, effort, systemPromptPath, catalogPath, violationPath, readyPath, Options.BridgeDllPath, Options.DotnetPath, Options.ServerName));
|
|
var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens);
|
|
|
|
if (streamOptions.OnPayload is not null)
|
|
{
|
|
var payload = new JsonObject
|
|
{
|
|
["provider"] = ClaudeCodeModels.ProviderId,
|
|
["model"] = model.Id,
|
|
["cwd"] = cwd,
|
|
["argv"] = new JsonArray(argv.Select(a => (JsonNode)JsonValue.Create(a)).ToArray()),
|
|
["stdin"] = stdin.DeepClone(),
|
|
};
|
|
var replacement = await streamOptions.OnPayload(payload).ConfigureAwait(false);
|
|
// Only the stdin message can be replaced: argv and the working directory are the transport's own.
|
|
if (replacement is JsonObject replaced && replaced["stdin"] is JsonObject replacedStdin)
|
|
stdin = (JsonObject)replacedStdin.DeepClone();
|
|
}
|
|
var stdinLine = stdin.ToJsonString(StdinOptions);
|
|
|
|
var totalTimeout = ClaudeProcess.TimeoutSetting(Options.GetEnv, ClaudeProcess.TotalTimeoutVariable, ClaudeProcess.DefaultTotalTimeout);
|
|
if (streamOptions.Timeout is { } requested && requested > TimeSpan.Zero && requested < totalTimeout) totalTimeout = requested;
|
|
var idleTimeout = Min(ClaudeProcess.TimeoutSetting(Options.GetEnv, ClaudeProcess.IdleTimeoutVariable, ClaudeProcess.DefaultIdleTimeout), totalTimeout);
|
|
var readyTimeout = Min(ClaudeProcess.TimeoutSetting(Options.GetEnv, McpReadyTimeoutVariable, DefaultMcpReadyTimeout), totalTimeout);
|
|
|
|
// Phase 2: spawn Claude in the session directory.
|
|
ct.ThrowIfCancellationRequested();
|
|
process = ClaudeProcess.Start(new ClaudeProcessOptions(claudePath, argv, cwd, env, idleTimeout, totalTimeout));
|
|
var claude = process;
|
|
using var abortRegistration = ct.Register(() => _ = claude.TerminateAsync());
|
|
|
|
if (readyPath is not null)
|
|
await WaitForReadyAsync(readyPath, readyTimeout, process, argv, ct).ConfigureAwait(false);
|
|
await process.WriteStdinAsync(stdinLine, ct).ConfigureAwait(false);
|
|
|
|
// Phase 3: consume and validate the ordered JSONL protocol.
|
|
await foreach (var record in JsonlReader.ReadAsync(process.Stdout, cancellationToken: ct).ConfigureAwait(false))
|
|
{
|
|
process.Touch();
|
|
if (streamOptions.OnProviderStreamEvent is not null) await streamOptions.OnProviderStreamEvent(record).ConfigureAwait(false);
|
|
var events = mapper.Accept(record);
|
|
if (!responseAnnounced && mapper.SawInit)
|
|
{
|
|
// A validated initialization is this transport's analogue of a received response; announce it before
|
|
// any content so after-response observers run ahead of the Start event.
|
|
responseAnnounced = true;
|
|
if (streamOptions.OnResponse is not null) await streamOptions.OnResponse(new ProviderResponseInfo(200, NoHeaders)).ConfigureAwait(false);
|
|
}
|
|
foreach (var e in events) await writer.WriteAsync(e, CancellationToken.None).ConfigureAwait(false);
|
|
if (mapper.HandoffLatched && !terminatedForHandoff)
|
|
{
|
|
// The proposal (or the length stop) is complete; Claude Code would now run a turn of its own.
|
|
terminatedForHandoff = true;
|
|
_ = process.TerminateAsync();
|
|
}
|
|
if (mapper.SawResult) break;
|
|
}
|
|
|
|
// Phase 4: validate the exit and private state before publishing success.
|
|
var exitCode = await WaitForExitAsync(process, ct).ConfigureAwait(false);
|
|
if (process.TimeoutError is { } timeout) throw timeout;
|
|
ct.ThrowIfCancellationRequested();
|
|
if (violationPath is not null && File.Exists(violationPath))
|
|
throw new ClaudeCodeException(ClaudeCodeException.Codes.McpExecution, "Security invariant violated: Claude Code attempted to execute a proposal tool internally");
|
|
if (mapper.DeferredError is { } deferred)
|
|
throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolStream, deferred);
|
|
|
|
AssistantMessageEvent done;
|
|
if (mapper.HandoffLatched)
|
|
{
|
|
// The provider terminated Claude on purpose: 143 (SIGTERM), 137 (SIGKILL), or whatever a late exit reports.
|
|
done = mapper.Complete();
|
|
}
|
|
else if (mapper.HasSuccessfulResult)
|
|
{
|
|
if (exitCode != 0)
|
|
throw new ClaudeCodeException(ClaudeCodeException.Codes.ProcessExit, $"Claude Code exited after a successful result (code {exitCode}){StderrDetail(process, tempDir)}");
|
|
done = mapper.Complete();
|
|
}
|
|
else
|
|
{
|
|
// A rejected rate limit explains the missing terminal better than the exit code (and the core retries it).
|
|
throw new ClaudeCodeException(ClaudeCodeException.Codes.ProcessExit,
|
|
mapper.RateLimitFailure ?? $"Claude Code exited before a terminal event (code {exitCode}){StderrDetail(process, tempDir)}");
|
|
}
|
|
|
|
Cleanup(ref tempDir);
|
|
await writer.WriteAsync(done, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
var aborted = ct.IsCancellationRequested || ex is OperationCanceledException;
|
|
if (process is not null) await process.TerminateAsync().ConfigureAwait(false);
|
|
var failure = builder.IsFinished
|
|
? null
|
|
: aborted ? builder.Fail("aborted", aborted: true) : builder.Fail(FormatFailure(ex));
|
|
if (failure is not null) await writer.WriteAsync(failure, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
process?.Dispose();
|
|
Cleanup(ref tempDir);
|
|
if (mapper is not null && notify is not null)
|
|
{
|
|
foreach (var notice in mapper.Notices)
|
|
{
|
|
try { notify(notice, NotifyKind.Warning); } catch (Exception) { /* notifications are advisory */ }
|
|
}
|
|
}
|
|
writer.TryComplete();
|
|
}
|
|
}
|
|
|
|
private static async Task WaitForReadyAsync(string readyPath, TimeSpan timeout, ClaudeProcess process, IReadOnlyList<string> argv, CancellationToken ct)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
if (File.Exists(readyPath)) return;
|
|
if (process.HasExited)
|
|
{
|
|
var code = await process.WaitForExitAsync(ct).ConfigureAwait(false);
|
|
throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup,
|
|
$"Claude Code exited before the proposal MCP server became ready (code {code}){ReadyDiagnostics(process, argv)}");
|
|
}
|
|
await Task.Delay(ReadyPollInterval, ct).ConfigureAwait(false);
|
|
}
|
|
if (File.Exists(readyPath)) return;
|
|
// Name the bridge launch: this timeout is far more often an unlaunchable bridge than a slow one.
|
|
throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup,
|
|
$"The proposal MCP server did not become ready within {timeout.TotalMilliseconds:0}ms{ReadyDiagnostics(process, argv)}");
|
|
}
|
|
|
|
private static string ReadyDiagnostics(ClaudeProcess process, IReadOnlyList<string> argv)
|
|
{
|
|
var mcpIndex = argv.ToList().IndexOf("--mcp-config");
|
|
var command = mcpIndex >= 0 && mcpIndex + 1 < argv.Count ? $"; Claude Code was told to launch the bridge from --mcp-config {argv[mcpIndex + 1]}" : "";
|
|
var stderr = process.StderrExcerpt();
|
|
return $"{command}{(stderr.Length > 0 ? $"; Claude Code stderr: {stderr}" : "")}; run /{DoctorCommand.Name} to complete the handshake directly";
|
|
}
|
|
|
|
private static async Task<int> WaitForExitAsync(ClaudeProcess process, CancellationToken ct)
|
|
{
|
|
using var grace = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
grace.CancelAfter(ExitGrace);
|
|
try
|
|
{
|
|
return await process.WaitForExitAsync(grace.Token).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
|
{
|
|
// stdout closed but the process lingers: terminate the tree and settle.
|
|
await process.TerminateAsync().ConfigureAwait(false);
|
|
return await process.WaitForExitAsync(ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private static string StderrDetail(ClaudeProcess process, string? tempDir)
|
|
{
|
|
var excerpt = process.StderrExcerpt(privatePaths: tempDir is null ? null : [tempDir]);
|
|
return excerpt.Length == 0 ? "" : $": {excerpt}";
|
|
}
|
|
|
|
private static string FormatFailure(Exception ex) => ex switch
|
|
{
|
|
ClaudeCodeException claude => claude.FormatForUser(),
|
|
_ => $"[provider] {ex.GetType().Name}: {ex.Message}",
|
|
};
|
|
|
|
private static void Cleanup(ref string? tempDir)
|
|
{
|
|
if (tempDir is null) return;
|
|
var directory = tempDir;
|
|
tempDir = null;
|
|
try
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
{
|
|
// Best effort: a stale request directory under the temp root is harmless.
|
|
}
|
|
}
|
|
|
|
private static TimeSpan Min(TimeSpan left, TimeSpan right) => left < right ? left : right;
|
|
}
|