Files
VolcanicAgenticCore/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs

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__&lt;name&gt;__&lt;alias&gt;</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;
}