forked from admin/VolcanicAgenticCore
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.
484 lines
27 KiB
C#
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__<name>__<alias></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;
|
|
}
|