feat(claude-code): pause an expired request, log in through the host UI and replay it

This commit is contained in:
EugeneTes
2026-09-25 17:25:43 +00:00
parent 521d391527
commit f90bf862f4
9 changed files with 362 additions and 6 deletions

View File

@@ -30,6 +30,15 @@ public sealed record ClaudeCodeApiOptions
/// <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>
@@ -41,6 +50,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
{
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>();
@@ -73,6 +83,75 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
}
private async Task RunRequestAsync(Model model, LlmContext context, StreamOptions streamOptions, ChannelWriter<AssistantMessageEvent> writer, CancellationToken ct)
{
try
{
var loginUi = Options.LoginUi;
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.
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 (Options.Coordinator ?? ClaudeLoginCoordinator.Shared)
.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);
if (string.IsNullOrEmpty(Options.ClaudePath)) return LoginOutcome.Failed("Claude Code (claude) was not found");
var ui = await loginUi(ct).ConfigureAwait(false);
if (ui is null) return LoginOutcome.Failed("the owner could not be reached; the first allowed user must start a chat with the bot");
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);
if (string.IsNullOrEmpty(Options.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(Options.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 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.
/// </summary>
private async Task<ClaudeCodeException?> RunAttemptAsync(Model model, LlmContext context, StreamOptions streamOptions,
ChannelWriter<AssistantMessageEvent> writer, bool deferLoginFailure, CancellationToken ct)
{
var builder = new AssistantMessageBuilder(model);
StreamEventMapper? mapper = null;
@@ -82,6 +161,26 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
Action<string, NotifyKind>? notify = null;
var terminatedForHandoff = false;
var responseAnnounced = false;
var wroteAny = false;
AssistantMessageEvent? heldStart = null;
// A deferring attempt holds the init Start back until content follows, so a replay never publishes two Starts.
async ValueTask PublishAsync(AssistantMessageEvent e)
{
if (deferLoginFailure && !wroteAny && heldStart is null && e is AssistantMessageEvent.Start)
{
heldStart = e;
return;
}
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();
@@ -173,7 +272,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
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);
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.
@@ -204,6 +303,12 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
throw new ClaudeCodeException(ClaudeCodeException.Codes.ProcessExit, $"Claude Code exited after a successful result (code {exitCode}){StderrDetail(process, tempDir)}");
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).
@@ -212,16 +317,19 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
}
Cleanup(ref tempDir);
await writer.WriteAsync(done, CancellationToken.None).ConfigureAwait(false);
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;
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);
if (failure is not null) await PublishAsync(failure).ConfigureAwait(false);
return null;
}
finally
{
@@ -234,7 +342,6 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
try { notify(notice, NotifyKind.Warning); } catch (Exception) { /* notifications are advisory */ }
}
}
writer.TryComplete();
}
}
@@ -289,8 +396,10 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
return excerpt.Length == 0 ? "" : $": {excerpt}";
}
private static string FormatFailure(Exception ex) => ex switch
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}",
};

View File

@@ -16,6 +16,15 @@ public sealed record ClaudeCodeProviderOptions
/// <summary>The registry to use; the process-wide <see cref="SessionRegistry.Shared"/> when null.</summary>
public SessionRegistry? Registry { get; init; }
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>
@@ -48,6 +57,9 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
DotnetPath = _options.DotnetPath ?? BridgeLocator.FindDotnet(getEnv),
GetEnv = getEnv,
ServerName = _options.ServerName,
LoginUi = _options.LoginUi,
Coordinator = _options.Coordinator ?? ClaudeLoginCoordinator.Shared,
LoginOptions = _options.LoginOptions,
};
var claude = new ClaudeCodeApi(registry, apiOptions);
Api = claude;

View File

@@ -78,6 +78,9 @@ public sealed partial class StreamEventMapper
/// <summary>A held tool-argument failure for the provider to report when no recovery signal claimed it.</summary>
public string? DeferredError => _deferredToolArguments;
/// <summary>Claude Code attributed a failure to an expired session (<c>authentication_failed</c>); an exit with no terminal record is then login-required.</summary>
public bool AuthenticationFailed => _authenticationFailed;
/// <summary>The most recent rejected rate limit, used as the failure detail when Claude exits without a terminal event.</summary>
public string? RateLimitFailure => _rateLimitFailure;

View File

@@ -0,0 +1,22 @@
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary><see cref="ILoginPrompt"/> over a host dialog surface: the link as a notification, the code as a free-text answer.</summary>
public sealed class UiLoginPrompt(IUserInterface ui) : ILoginPrompt
{
public const string CodeQuestion = "Paste the Claude login code";
private readonly IUserInterface _ui = ui ?? throw new ArgumentNullException(nameof(ui));
public async Task<string?> AskForCodeAsync(string url, int attempt, CancellationToken cancellationToken)
{
_ui.Notify("Claude Code needs you to sign in again.\n" +
$"1. Open: {url}\n" +
"2. Sign in with the Claude subscription account.\n" +
"3. Paste the code shown on the page as a reply.");
return await _ui.InputAsync(CodeQuestion, "code", null, cancellationToken).ConfigureAwait(false);
}
public void Report(string message) => _ui.Notify(message, NotifyKind.Warning);
}