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; /// Where the CLI, the bridge and the muxer live, plus environment access (overridable for tests). public sealed record ClaudeCodeApiOptions { /// The claude executable; null when it was not located (every request then fails with executable_missing). public string? ClaudePath { get; init; } /// The proposal bridge assembly; null when it was not located (tool-bearing requests then fail with mcp_startup). public string? BridgeDllPath { get; init; } /// The dotnet muxer that runs the bridge. public string? DotnetPath { get; init; } /// Environment lookup for the timeout settings. public Func GetEnv { get; init; } = Environment.GetEnvironmentVariable; /// The environment the allow-list is applied to; the process environment when null. public IReadOnlyDictionary? ParentEnvironment { get; init; } /// /// The IANA zone for the child's TZ, read per request (the extension reads the session's /// IExtensionContext.TimeZone, so SetTimeZone applies from the next model call). Null or a null result /// leaves TZ unset. /// public Func? TimeZoneId { get; init; } /// The MCP server name the bridge is registered under (tools become mcp__<name>__<alias>). public string ServerName { get; init; } = ToolCatalog.DefaultServerName; /// Where the login link goes when the session expires; null disables the automatic login (headless hosts). public Func>? LoginUi { get; init; } /// The single-flight login coordinator; when null. public ClaudeLoginCoordinator? Coordinator { get; init; } /// Timeouts and retry budget for the automatic login; the defaults when null. public ClaudeLoginOptions? LoginOptions { get; init; } /// The built-in plugins every request disables; when null. public BuiltinPlugins? BuiltinPlugins { get; init; } } /// /// The claude-code-headless API: fully stateless. Every request spawns a fresh claude -p, replays the whole /// transcript as one stream-json user message, exposes the active tools through the proposal bridge, captures the /// model's tool_use as a proposal (terminating Claude at the stop), and never throws out of . /// 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); public const string HeadlessLoginMessage = "Claude Code session expired; run \"claude auth login\" on the host (or /claude-code-login from a chat UI)"; private static readonly TimeSpan ReadyPollInterval = TimeSpan.FromMilliseconds(25); private static readonly TimeSpan ExitGrace = TimeSpan.FromSeconds(10); private static readonly IReadOnlyDictionary NoHeaders = new Dictionary(); 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 StreamAsync( Model model, LlmContext context, StreamOptions streamOptions, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var channel = Channel.CreateUnbounded(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 writer, CancellationToken ct) { try { var loginUi = Options.LoginUi; var coordinator = Options.Coordinator ?? ClaudeLoginCoordinator.Shared; var generation = coordinator.Generation; var expired = await RunAttemptAsync(model, context, streamOptions, writer, deferLoginFailure: loginUi is not null, ct).ConfigureAwait(false); if (expired is null || loginUi is null) return; // The session expired before anything was published: pause, let the owner log in, then replay exactly once. // A login that succeeded after this attempt started makes the expiry stale: replay without another flow. if (coordinator.Generation != generation) { await RunAttemptAsync(model, context, streamOptions, writer, deferLoginFailure: false, ct).ConfigureAwait(false); return; } var builder = new AssistantMessageBuilder(model); Notify(streamOptions.SessionId, "Claude Code session expired; waiting for the bot owner to sign in…", NotifyKind.Warning); LoginOutcome outcome; try { outcome = await coordinator.EnsureLoggedInAsync(flowCt => RunLoginFlowAsync(loginUi, flowCt), ct).ConfigureAwait(false); } catch (OperationCanceledException) { await writer.WriteAsync(builder.Fail("aborted", aborted: true), CancellationToken.None).ConfigureAwait(false); return; } if (!outcome.Success) { await writer.WriteAsync(builder.Fail($"[{ClaudeCodeException.Codes.Auth}] Claude Code login failed: {outcome.Error}"), CancellationToken.None).ConfigureAwait(false); return; } await RunAttemptAsync(model, context, streamOptions, writer, deferLoginFailure: false, ct).ConfigureAwait(false); } finally { writer.TryComplete(); } } /// The login flow the coordinator runs once for every waiting request: resolve the owner, drive the CLI, report. public async Task RunLoginFlowAsync(Func> loginUi, CancellationToken ct) { ArgumentNullException.ThrowIfNull(loginUi); const string unreachable = "the owner could not be reached; the first allowed user must start a chat with the bot"; IUserInterface? ui; using (var resolve = CancellationTokenSource.CreateLinkedTokenSource(ct)) { resolve.CancelAfter((Options.LoginOptions ?? new ClaudeLoginOptions()).OwnerTimeout); try { ui = await loginUi(resolve.Token).WaitAsync(resolve.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { ui = null; } } if (ui is null) return LoginOutcome.Failed(unreachable); return await LoginThroughAsync(ui, ct).ConfigureAwait(false); } /// Runs the CLI login with as the prompt and reports the result there. public async Task LoginThroughAsync(IUserInterface ui, CancellationToken ct) { ArgumentNullException.ThrowIfNull(ui); var claudePath = Options.ClaudePath; if (string.IsNullOrEmpty(claudePath) || !File.Exists(claudePath)) return LoginOutcome.Failed("Claude Code (claude) was not found"); var env = ClaudeArgs.AllowListed(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens: null); var outcome = await ClaudeLogin.RunAsync(claudePath, env, new UiLoginPrompt(ui), Options.LoginOptions, ct).ConfigureAwait(false); if (outcome.Success) ui.Notify("Claude Code login complete ✓"); else ui.Notify($"Claude Code login failed: {outcome.Error}", NotifyKind.Error); return outcome; } private void Notify(string? sessionId, string message, NotifyKind kind) { if (sessionId is null || Registry.Get(sessionId)?.Notify is not { } notify) return; try { notify(message, kind); } catch (Exception) { /* notifications are advisory */ } } /// /// One request attempt. When Claude Code loads built-in plugins the provider doesn't disable yet (a new CLI release or /// feature flag), nothing has been published: disable them for this host process and replay exactly once. /// private async Task RunAttemptAsync(Model model, LlmContext context, StreamOptions streamOptions, ChannelWriter writer, bool deferLoginFailure, CancellationToken ct) { var plugins = Options.BuiltinPlugins ?? BuiltinPlugins.Shared; var failure = await RunProcessAsync(model, context, streamOptions, writer, deferLoginFailure, plugins, deferBuiltinPlugins: true, ct).ConfigureAwait(false); if (failure is not { UnexpectedBuiltinPlugins.Count: > 0 } builtin) return failure; plugins.Disable(builtin.UnexpectedBuiltinPlugins); return await RunProcessAsync(model, context, streamOptions, writer, deferLoginFailure, plugins, deferBuiltinPlugins: false, ct).ConfigureAwait(false); } /// /// One Claude process run. Publishes its events and its terminal event, except that with /// a login-required failure arriving before any content is returned unpublished /// (the held-back Start is dropped) so the caller can log in and replay. With /// an init that loaded unexpected built-in plugins is returned unpublished the same way. /// private async Task RunProcessAsync(Model model, LlmContext context, StreamOptions streamOptions, ChannelWriter writer, bool deferLoginFailure, BuiltinPlugins plugins, bool deferBuiltinPlugins, CancellationToken ct) { var builder = new AssistantMessageBuilder(model); StreamEventMapper? mapper = null; ClaudeProcess? process = null; string? tempDir = null; string? violationPath = null; Action? notify = null; var terminatedForHandoff = false; var responseAnnounced = false; var wroteAny = false; var responsePending = false; AssistantMessageEvent? heldStart = null; // After-response observers run once per published message, ahead of its Start. async ValueTask AnnounceResponseAsync() { if (!responsePending) return; responsePending = false; if (streamOptions.OnResponse is not null) await streamOptions.OnResponse(new ProviderResponseInfo(200, NoHeaders)).ConfigureAwait(false); } // A deferring attempt holds the init Start (and the response announcement) back until something else is published, // so a replay never publishes two Starts and a dropped attempt is never announced. async ValueTask PublishAsync(AssistantMessageEvent e) { if (deferLoginFailure && !wroteAny && heldStart is null && e is AssistantMessageEvent.Start) { heldStart = e; return; } await AnnounceResponseAsync().ConfigureAwait(false); if (heldStart is { } held) { heldStart = null; await writer.WriteAsync(held, CancellationToken.None).ConfigureAwait(false); } wroteAny = true; await writer.WriteAsync(e, CancellationToken.None).ConfigureAwait(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, plugins.Disabled)); var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens, Options.TimeZoneId?.Invoke()); 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; responsePending = true; if (!deferLoginFailure) await AnnounceResponseAsync().ConfigureAwait(false); } foreach (var e in events) await PublishAsync(e).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. await AnnounceResponseAsync().ConfigureAwait(false); 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)}"); // Announced before Complete finishes the builder, so a throwing observer still gets a failure published. await AnnounceResponseAsync().ConfigureAwait(false); done = mapper.Complete(); } else if (mapper.AuthenticationFailed) { // Claude Code gave up on an expired session without a result record: still a login-required failure. throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth, $"Claude Code session expired: authentication failed before a terminal event (code {exitCode}){StderrDetail(process, tempDir)}", loginRequired: true); } 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 PublishAsync(done).ConfigureAwait(false); return null; } catch (Exception ex) { var aborted = ct.IsCancellationRequested || ex is OperationCanceledException; if (process is not null) await process.TerminateAsync().ConfigureAwait(false); if (deferLoginFailure && !wroteAny && !aborted && ex is ClaudeCodeException { LoginRequired: true } login) return login; if (deferBuiltinPlugins && !wroteAny && !aborted && ex is ClaudeCodeException { UnexpectedBuiltinPlugins.Count: > 0 } builtin) return builtin; if (responsePending) { // The request is already failing; an observer error must not keep the failure from being published. try { await AnnounceResponseAsync().ConfigureAwait(false); } catch (Exception) { /* already failing */ } } var failure = builder.IsFinished ? null : aborted ? builder.Fail("aborted", aborted: true) : builder.Fail(FormatFailure(ex)); if (failure is not null) await PublishAsync(failure).ConfigureAwait(false); return null; } 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 */ } } } } } private static async Task WaitForReadyAsync(string readyPath, TimeSpan timeout, ClaudeProcess process, IReadOnlyList 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 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 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 string FormatFailure(Exception ex) => ex switch { // Without a login channel, say how to clear the expiry by hand. ClaudeCodeException { LoginRequired: true } when Options.LoginUi is null => $"[{ClaudeCodeException.Codes.Auth}] {HeadlessLoginMessage}", 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; }