Files
Playa/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs
EugeneTes 094d518827 fix(claude-code): disable built-in plugins and learn new ones from init
Claude Code 2.1.287 loads cc-plugin-* built-ins regardless of --setting-sources,
which tripped the fail-closed init check. Disable every known built-in in
--settings, and when init lists only built-ins that slipped through, disable them
process-wide and replay the request once. Other plugins still fail closed.
2026-10-02 10:08:23 +00:00

484 lines
27 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 IANA zone for the child's <c>TZ</c>, read per request (the extension reads the session's
/// <c>IExtensionContext.TimeZone</c>, so <c>SetTimeZone</c> applies from the next model call). Null or a null result
/// leaves <c>TZ</c> unset.
/// </summary>
public Func<string?>? TimeZoneId { 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>Where the login link goes when the session expires; null disables the automatic login (headless hosts).</summary>
public Func<CancellationToken, Task<IUserInterface?>>? LoginUi { get; init; }
/// <summary>The single-flight login coordinator; <see cref="ClaudeLoginCoordinator.Shared"/> when null.</summary>
public ClaudeLoginCoordinator? Coordinator { get; init; }
/// <summary>Timeouts and retry budget for the automatic login; the defaults when null.</summary>
public ClaudeLoginOptions? LoginOptions { get; init; }
/// <summary>The built-in plugins every request disables; <see cref="BuiltinPlugins.Shared"/> when null.</summary>
public BuiltinPlugins? BuiltinPlugins { get; init; }
}
/// <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);
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<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)
{
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();
}
}
/// <summary>The login flow the coordinator runs once for every waiting request: resolve the owner, drive the CLI, report.</summary>
public async Task<LoginOutcome> RunLoginFlowAsync(Func<CancellationToken, Task<IUserInterface?>> 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);
}
/// <summary>Runs the CLI login with <paramref name="ui"/> as the prompt and reports the result there.</summary>
public async Task<LoginOutcome> 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 */ }
}
/// <summary>
/// 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.
/// </summary>
private async Task<ClaudeCodeException?> RunAttemptAsync(Model model, LlmContext context, StreamOptions streamOptions,
ChannelWriter<AssistantMessageEvent> 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);
}
/// <summary>
/// One Claude process run. Publishes its events and its terminal event, except that with
/// <paramref name="deferLoginFailure"/> a login-required failure arriving before any content is returned unpublished
/// (the held-back <c>Start</c> is dropped) so the caller can log in and replay. With <paramref name="deferBuiltinPlugins"/>
/// an init that loaded unexpected built-in plugins is returned unpublished the same way.
/// </summary>
private async Task<ClaudeCodeException?> RunProcessAsync(Model model, LlmContext context, StreamOptions streamOptions,
ChannelWriter<AssistantMessageEvent> 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<string, NotifyKind>? 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<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 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;
}