docs(plan): Claude Code re-login implementation plan

This commit is contained in:
EugeneTes
2026-09-25 16:51:28 +00:00
parent ec97f50fdd
commit 61743d8e3a

View File

@@ -0,0 +1,921 @@
# Claude Code Re-login Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** When the `claude` CLI's subscription session expires, the Claude Code provider pauses the request, sends the bot owner a login link through the host UI, takes the pasted code, completes `claude auth login` and replays the request.
**Architecture:** Detection lives in `StreamEventMapper` (a `LoginRequired` flag on `ClaudeCodeException`). A UI-agnostic `ClaudeLogin` drives `claude auth login --claudeai` over pipes through an `ILoginPrompt`. `ClaudeLoginCoordinator` keeps it to one flow per process. `ClaudeCodeApi` retries once after a successful login. The host supplies `ClaudeCodeProviderOptions.LoginUi` (Telegram: the first allowed user's chat).
**Tech Stack:** .NET 10, C#, xUnit, Telegram.Bot. Spec: `docs/superpowers/specs/2026-09-25-claude-code-relogin-design.md`.
**Conventions:** Match surrounding code: file-scoped namespaces, `ConfigureAwait(false)`, XML `<summary>` doc comments on public types, sealed classes/records. Tests that spawn shell scripts return early when `!Fixtures.IsPosix`. Build/test: `dotnet test VolcanicAgenticCore.slnx` from the repo root (filter with `--filter "FullyQualifiedName~<Class>"`).
---
## File map
| File | Change |
|---|---|
| `extensions/…ClaudeCode/ClaudeCodeException.cs` | add `LoginRequired` |
| `extensions/…ClaudeCode/StreamEventMapper.cs` | classify expired-session results |
| `extensions/…ClaudeCode/ClaudeLogin.cs` | **new**: `ILoginPrompt`, `LoginOutcome`, `ClaudeLoginOptions`, `ClaudeLogin` |
| `extensions/…ClaudeCode/ClaudeLoginCoordinator.cs` | **new**: single-flight coordinator |
| `extensions/…ClaudeCode/UiLoginPrompt.cs` | **new**: `ILoginPrompt` over `IUserInterface` |
| `extensions/…ClaudeCode/ClaudeCodeApi.cs` | options `LoginUi`/`Coordinator`/`LoginOptions`; retry after login |
| `extensions/…ClaudeCode/ClaudeCodeProviderExtension.cs` | new options, register `/claude-code-login` |
| `extensions/…ClaudeCode/LoginCommand.cs` | **new**: `/claude-code-login` |
| `extensions/…ClaudeCode/DoctorCommand.cs` | "not logged in" hint |
| `hosts/Volcanic.AgenticCore.Telegram/Program.cs` | register `ClaudeCodeProviderOptions` with `LoginUi` |
| `hosts/Volcanic.AgenticCore.Telegram/Chats/OwnerLoginChannel.cs` | **new**: resolves the owner chat's UI |
| tests under `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/` and `tests/Volcanic.AgenticCore.Telegram.Tests/` | new tests + 2 fixtures |
| `docs/claude-code-provider.md`, `docs/telegram-bot.md` | document the flow |
---
### Task 1: Detect an expired session
**Files:**
- Modify: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeException.cs`
- Modify: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/StreamEventMapper.cs` (`AcceptResult` ~line 427, `RejectInterruptedStream` ~line 503, the record dispatch that handles `system/api_retry`)
- Create: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures/claude-2.1.281-stream-auth-expired.jsonl`
- Create: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures/claude-2.1.281-stream-auth-login.jsonl`
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/StreamEventMapperTests.cs`
- [ ] **Step 1: Fixtures.** Copy the first line (the `system/init` record) of `claude-2.1.281-stream-http529.jsonl` as the first line of both new files, so the init validation passes with the probe catalogue. Then add:
`claude-2.1.281-stream-auth-expired.jsonl`, after the init line:
```
{"type":"system","subtype":"status","status":"requesting","session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000002"}
{"type":"assistant","message":{"id":"00000000-0000-4000-8000-00000000000a","model":"<synthetic>","role":"assistant","type":"message","content":[{"type":"text","text":"Failed to authenticate. API Error: 401 {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"OAuth token has expired. Please obtain a new token or refresh your existing token.\"}}"}]},"error":"authentication_failed","session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000003"}
{"type":"result","subtype":"success","is_error":true,"api_error_status":401,"duration_ms":312,"num_turns":1,"result":"Failed to authenticate. API Error: 401 OAuth token has expired. Please obtain a new token or refresh your existing token.","stop_reason":"stop_sequence","session_id":"00000000-0000-4000-8000-000000000000","total_cost_usd":0,"usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},"uuid":"00000000-0000-4000-8000-000000000004"}
```
`claude-2.1.281-stream-auth-login.jsonl`, after the init line:
```
{"type":"assistant","message":{"id":"00000000-0000-4000-8000-00000000000a","model":"<synthetic>","role":"assistant","type":"message","content":[{"type":"text","text":"Invalid API key · Please run /login"}]},"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000003"}
{"type":"result","subtype":"success","is_error":true,"duration_ms":120,"num_turns":1,"result":"Invalid API key · Please run /login","stop_reason":"stop_sequence","session_id":"00000000-0000-4000-8000-000000000000","total_cost_usd":0,"usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},"uuid":"00000000-0000-4000-8000-000000000004"}
```
Add a line for both to `Fixtures/README.md` saying they're hand-written from Claude's documented 401 output. Check the test csproj copies `Fixtures/*.jsonl` by glob (it already does for the others; if it lists files, add these).
- [ ] **Step 2: Failing tests.** Find how the existing `StreamEventMapperTests` drive a whole fixture: look at the test for `http529` or `prompt-too-long`, and the helper that feeds `Fixtures.StreamRecords(scenario)` into `new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog).Accept(...)`. Reuse that helper. Add:
```csharp
[Theory]
[InlineData("auth-expired")]
[InlineData("auth-login")]
public void Expired_session_is_a_login_required_auth_failure(string scenario)
{
var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
var ex = Assert.Throws<ClaudeCodeException>(() => { foreach (var r in Fixtures.StreamRecords(scenario)) mapper.Accept(r); });
Assert.Equal(ClaudeCodeException.Codes.Auth, ex.Code);
Assert.True(ex.LoginRequired);
Assert.StartsWith("Claude Code session expired", ex.Message);
}
[Fact]
public void Other_error_results_do_not_require_login()
{
var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
var ex = Assert.Throws<ClaudeCodeException>(() => { foreach (var r in Fixtures.StreamRecords("sse-error")) mapper.Accept(r); });
Assert.False(ex.LoginRequired);
}
[Fact]
public void Api_key_source_failure_does_not_require_login()
{
var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
var ex = Assert.Throws<ClaudeCodeException>(() => mapper.Accept(Fixtures.ProbeInit(apiKeySource: "ANTHROPIC_API_KEY")));
Assert.Equal(ClaudeCodeException.Codes.Auth, ex.Code);
Assert.False(ex.LoginRequired);
}
```
If the `sse-error` fixture doesn't end in an exception from `Accept` (check its existing test), use whichever existing fixture whose test asserts a `protocol_stream` throw.
- [ ] **Step 3: Run it; expect a compile failure (`LoginRequired` missing).** `dotnet test VolcanicAgenticCore.slnx --filter "FullyQualifiedName~StreamEventMapperTests"`
- [ ] **Step 4: Implement.** `ClaudeCodeException`:
```csharp
public sealed class ClaudeCodeException(string code, string message, bool retryable = false, bool loginRequired = false) : Exception(message)
{
...
/// <summary>True when the CLI's subscription session expired and <c>claude auth login</c> would clear the failure.</summary>
public bool LoginRequired { get; } = loginRequired;
```
`StreamEventMapper`:
```csharp
private static readonly string[] LoginPhrases =
["OAuth token has expired", "Please run /login", "Invalid API key", "authentication_failed", "Not logged in"];
private bool _authenticationFailed;
/// <summary>Whether an error result's detail (or status) says the subscription session is gone.</summary>
public static bool IsLoginRequired(int? status, string? detail) =>
status == 401 || (detail is not null && LoginPhrases.Any(p => detail.Contains(p, StringComparison.OrdinalIgnoreCase)));
```
- Where `system/api_retry` records are handled (before `RejectInterruptedStream`'s early return), set `_authenticationFailed = true` when `Str(record, "error") == "authentication_failed"`. In `AcceptAssistant`, also set it when `Str(record, "error") == "authentication_failed"`.
- In `AcceptResult`'s `isError` branch, after `detail` is computed and before the overflow check:
```csharp
int? statusCode = record.TryGetProperty("api_error_status", out var s) && s.ValueKind == JsonValueKind.Number && s.TryGetInt32(out var code) ? code : null;
if (_authenticationFailed || IsLoginRequired(statusCode, detail))
throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth, $"Claude Code session expired: {Truncate(detail, 512)}", loginRequired: true);
```
(`Truncate` already exists in the mapper; if its signature differs, adapt.) `ThrowDeferredFailure()` runs before this check. If a deferred rate-limit failure could mask a 401, move the login check above `ThrowDeferredFailure()`.
- [ ] **Step 5: Run the mapper tests, then the full extension test project; all pass.**
- [ ] **Step 6: Commit** `git commit -m "feat(claude-code): classify an expired subscription session as login-required auth failure"`
---
### Task 2: `ClaudeLogin` — drive `claude auth login` over pipes
**Files:**
- Create: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeLogin.cs`
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeLoginTests.cs`
The real CLI prints (verified on 2.1.280):
```
Opening browser to sign in…
If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&client_id=…&state=…
Paste code here if prompted >
```
The prompt has **no trailing newline**, so read stdout in chunks, not by line. Then it reads the code line from stdin and exits.
- [ ] **Step 1: Failing tests.** The fake `claude` script handles `auth login`, which prints a URL containing an attempt counter, reads one line and writes it to `$STATE/code`, exiting 0. It also handles `auth status`, which prints `loggedIn: true` only if the stored code equals `good`.
```csharp
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
public class ClaudeLoginTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("claude-login-test-").FullName;
public void Dispose() { try { Directory.Delete(_root, true); } catch (IOException) { } }
private sealed class ScriptedPrompt(params string?[] answers) : ILoginPrompt
{
private int _next;
public List<string> Urls { get; } = [];
public List<string> Reports { get; } = [];
public Func<CancellationToken, Task>? BeforeAnswer { get; init; }
public async Task<string?> AskForCodeAsync(string url, int attempt, CancellationToken ct)
{
Urls.Add(url);
if (BeforeAnswer is not null) await BeforeAnswer(ct);
return _next < answers.Length ? answers[_next++] : null;
}
public void Report(string message) => Reports.Add(message);
}
/// <summary>printUrl=false simulates a CLI that exits without printing a login link.</summary>
private string FakeClaude(bool printUrl = true)
{
var script = Path.Combine(_root, "claude");
var state = Path.Combine(_root, "state");
Directory.CreateDirectory(state);
File.WriteAllText(script, $$"""
#!/bin/sh
STATE="{{state}}"
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then
n=$(cat "$STATE/n" 2>/dev/null || echo 0); n=$((n+1)); echo $n > "$STATE/n"
echo "Opening browser to sign in…"
{{(printUrl ? "echo \"If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&state=s$n\"" : "exit 3")}}
printf "Paste code here if prompted > "
read code
echo "$code" > "$STATE/code"
exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
if [ "$(cat "$STATE/code" 2>/dev/null)" = "good" ]; then
echo '{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty"}'
else
echo '{"loggedIn":false}'
fi
exit 0
fi
exit 1
""".Replace("\r\n", "\n"));
File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
return script;
}
private static IReadOnlyDictionary<string, string> Env() =>
new Dictionary<string, string> { ["PATH"] = Environment.GetEnvironmentVariable("PATH") ?? "/usr/bin:/bin" };
private static readonly ClaudeLoginOptions Fast = new() { UrlTimeout = TimeSpan.FromSeconds(5), ExchangeTimeout = TimeSpan.FromSeconds(5) };
[Fact]
public async Task Valid_code_logs_in()
{
if (!Fixtures.IsPosix) return;
var prompt = new ScriptedPrompt(" good ");
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, Fast, CancellationToken.None);
Assert.True(outcome.Success, outcome.Error);
Assert.Equal(["https://claude.com/cai/oauth/authorize?code=true&state=s1"], prompt.Urls);
}
[Fact]
public async Task Rejected_code_gets_a_new_link()
{
if (!Fixtures.IsPosix) return;
var prompt = new ScriptedPrompt("bad", "good");
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, Fast, CancellationToken.None);
Assert.True(outcome.Success, outcome.Error);
Assert.Equal(2, prompt.Urls.Count);
Assert.EndsWith("state=s2", prompt.Urls[1]);
Assert.Single(prompt.Reports);
}
[Fact]
public async Task Three_rejected_codes_fail()
{
if (!Fixtures.IsPosix) return;
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), new ScriptedPrompt("a", "b", "c", "good"), Fast, CancellationToken.None);
Assert.False(outcome.Success);
Assert.Equal("the code was rejected 3 times", outcome.Error);
}
[Fact]
public async Task No_answer_fails()
{
if (!Fixtures.IsPosix) return;
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), new ScriptedPrompt(), Fast, CancellationToken.None);
Assert.False(outcome.Success);
Assert.Equal("no code was entered", outcome.Error);
}
[Fact]
public async Task Missing_url_fails()
{
if (!Fixtures.IsPosix) return;
var outcome = await ClaudeLogin.RunAsync(FakeClaude(printUrl: false), Env(), new ScriptedPrompt("good"), Fast, CancellationToken.None);
Assert.False(outcome.Success);
Assert.StartsWith("claude auth login printed no login URL", outcome.Error);
}
[Fact]
public async Task Cancellation_throws()
{
if (!Fixtures.IsPosix) return;
using var cts = new CancellationTokenSource();
var prompt = new ScriptedPrompt("good") { BeforeAnswer = async ct => { cts.Cancel(); await Task.Delay(Timeout.Infinite, ct); } };
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, Fast, cts.Token));
}
}
```
`Fixtures.IsPosix` already exists (the other tests use it).
- [ ] **Step 2: Run; expect a compile failure.**
- [ ] **Step 3: Implement `ClaudeLogin.cs`.** Use `System.Diagnostics.Process` directly (not `ClaudeProcess`, which is built for the JSONL stream and closes stdin after one write; here a free-text read is needed). Mirror `ClaudeInstallation.RunAsync` for process start and kill.
```csharp
using System.ComponentModel;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>Where the login link goes and where the pasted code comes from.</summary>
public interface ILoginPrompt
{
/// <summary>Show the link and wait for the code. Null when there is no answer (timeout, cancelled dialog, unreachable user).</summary>
Task<string?> AskForCodeAsync(string url, int attempt, CancellationToken cancellationToken);
/// <summary>A progress or retry message for the person logging in.</summary>
void Report(string message);
}
public sealed record LoginOutcome(bool Success, string? Error)
{
public static readonly LoginOutcome Ok = new(true, null);
public static LoginOutcome Failed(string error) => new(false, error);
}
public sealed record ClaudeLoginOptions
{
public int MaxAttempts { get; init; } = 3;
public TimeSpan UrlTimeout { get; init; } = TimeSpan.FromSeconds(30);
public TimeSpan ExchangeTimeout { get; init; } = TimeSpan.FromSeconds(60);
public TimeSpan StatusTimeout { get; init; } = TimeSpan.FromSeconds(10);
}
/// <summary>
/// Runs <c>claude auth login --claudeai</c> headlessly: it prints the authorize URL and waits for the code on stdin.
/// Each attempt is a fresh process (its own PKCE state), so a rejected code gets a new link. Success is confirmed by
/// <c>claude auth status</c>. The URL and code never appear in errors.
/// </summary>
public static partial class ClaudeLogin
{
public static async Task<LoginOutcome> RunAsync(string claudePath, IReadOnlyDictionary<string, string> environment, ILoginPrompt prompt,
ClaudeLoginOptions? options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(prompt);
options ??= new ClaudeLoginOptions();
var env = new Dictionary<string, string>(environment, StringComparer.Ordinal) { ["BROWSER"] = OperatingSystem.IsWindows() ? "none" : "/bin/false" };
for (var attempt = 1; attempt <= options.MaxAttempts; attempt++)
{
var result = await AttemptAsync(claudePath, env, prompt, attempt, options, cancellationToken).ConfigureAwait(false);
if (result is not null) return result;
if (attempt < options.MaxAttempts) prompt.Report("That code was not accepted; here is a new link.");
}
return LoginOutcome.Failed($"the code was rejected {options.MaxAttempts} times");
}
/// <summary>A final outcome, or null when the code was rejected and another attempt may follow.</summary>
private static async Task<LoginOutcome?> AttemptAsync(string claudePath, Dictionary<string, string> env, ILoginPrompt prompt, int attempt,
ClaudeLoginOptions options, CancellationToken ct)
{
var psi = new ProcessStartInfo(claudePath)
{
UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true,
};
psi.ArgumentList.Add("auth"); psi.ArgumentList.Add("login"); psi.ArgumentList.Add("--claudeai");
psi.Environment.Clear();
foreach (var (name, value) in env) psi.Environment[name] = value;
using var process = new Process { StartInfo = psi };
try { process.Start(); }
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException)
{
return LoginOutcome.Failed($"claude auth login could not be started: {ex.Message}");
}
var stderr = new StringBuilder();
var stderrPump = PumpAsync(process.StandardError, stderr);
try
{
var url = await ReadUrlAsync(process, options.UrlTimeout, ct).ConfigureAwait(false);
if (url is null)
{
Kill(process);
return LoginOutcome.Failed($"claude auth login printed no login URL{Excerpt(stderr)}");
}
var code = (await prompt.AskForCodeAsync(url, attempt, ct).ConfigureAwait(false))?.Trim();
if (string.IsNullOrEmpty(code))
{
Kill(process);
return LoginOutcome.Failed("no code was entered");
}
try
{
await process.StandardInput.WriteLineAsync(code.AsMemory(), ct).ConfigureAwait(false);
await process.StandardInput.FlushAsync(ct).ConfigureAwait(false);
process.StandardInput.Close();
}
catch (IOException) { /* exited early; the status check decides */ }
using (var exchange = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
exchange.CancelAfter(options.ExchangeTimeout);
try { await process.WaitForExitAsync(exchange.Token).ConfigureAwait(false); }
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
Kill(process);
return null; // the code hung the exchange: treat as rejected
}
}
return process.ExitCode == 0 && await IsLoggedInAsync(claudePath, env, options.StatusTimeout, ct).ConfigureAwait(false)
? LoginOutcome.Ok
: null;
}
finally
{
Kill(process);
try { await stderrPump.WaitAsync(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); } catch (TimeoutException) { }
}
}
/// <summary>Reads stdout in chunks (the paste prompt has no newline) until the authorize URL appears.</summary>
private static async Task<string?> ReadUrlAsync(Process process, TimeSpan timeout, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
var text = new StringBuilder();
var buffer = new char[1024];
try
{
while (true)
{
var read = await process.StandardOutput.ReadAsync(buffer.AsMemory(), cts.Token).ConfigureAwait(false);
if (read == 0) return null;
text.Append(buffer, 0, read);
var match = UrlPattern().Match(text.ToString());
// The URL is complete once whitespace follows it.
if (match.Success && match.Index + match.Length < text.Length) return match.Value;
}
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return null;
}
}
private static async Task<bool> IsLoggedInAsync(string claudePath, IReadOnlyDictionary<string, string> env, TimeSpan timeout, CancellationToken ct)
{
var status = await ClaudeInstallation.RunAsync(claudePath, ["auth", "status"], env, timeout, ct).ConfigureAwait(false);
var json = status.Stdout.Trim();
var start = json.IndexOf('{'); var end = json.LastIndexOf('}');
if (start < 0 || end <= start) return false;
try
{
using var doc = JsonDocument.Parse(json[start..(end + 1)]);
var root = doc.RootElement;
var loggedIn = root.TryGetProperty("loggedIn", out var li) && li.ValueKind == JsonValueKind.True;
var method = root.TryGetProperty("authMethod", out var am) && am.ValueKind == JsonValueKind.String ? am.GetString() : null;
return loggedIn && method == "claude.ai";
}
catch (JsonException) { return false; }
}
private static async Task PumpAsync(StreamReader reader, StringBuilder sink)
{
var buffer = new char[1024];
int read;
try { while ((read = await reader.ReadAsync(buffer.AsMemory()).ConfigureAwait(false)) > 0) lock (sink) sink.Append(buffer, 0, read); }
catch (Exception ex) when (ex is IOException or ObjectDisposedException) { }
}
private static string Excerpt(StringBuilder stderr)
{
string text; lock (stderr) text = stderr.ToString().Trim();
if (text.Length > 300) text = text[^300..];
return text.Length == 0 ? "" : $": {text}";
}
private static void Kill(Process process)
{
try { if (!process.HasExited) process.Kill(entireProcessTree: true); }
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException or AggregateException) { }
}
[GeneratedRegex(@"https://\S*oauth/authorize\S*")]
private static partial Regex UrlPattern();
}
```
Check `ClaudeInstallation.RunAsync`'s exact signature and `ProbeResult` member names, and adapt. Cancellation must propagate as `OperationCanceledException` (the `Cancellation_throws` test). The `finally` kills the process.
- [ ] **Step 4: Run `--filter "FullyQualifiedName~ClaudeLoginTests"`; all pass.**
- [ ] **Step 5: Commit** `git commit -m "feat(claude-code): headless claude auth login driver"`
---
### Task 3: `ClaudeLoginCoordinator` — one flow at a time
**Files:**
- Create: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeLoginCoordinator.cs`
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeLoginCoordinatorTests.cs`
- [ ] **Step 1: Failing tests**
```csharp
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
public class ClaudeLoginCoordinatorTests
{
[Fact]
public async Task Concurrent_callers_share_one_flow()
{
var coordinator = new ClaudeLoginCoordinator();
var gate = new TaskCompletionSource<LoginOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
var runs = 0;
Func<CancellationToken, Task<LoginOutcome>> flow = _ => { Interlocked.Increment(ref runs); return gate.Task; };
var first = coordinator.EnsureLoggedInAsync(flow, CancellationToken.None);
var second = coordinator.EnsureLoggedInAsync(flow, CancellationToken.None);
gate.SetResult(LoginOutcome.Ok);
Assert.True((await first).Success);
Assert.True((await second).Success);
Assert.Equal(1, runs);
}
[Fact]
public async Task Failure_reaches_every_waiter_and_the_next_call_starts_a_new_flow()
{
var coordinator = new ClaudeLoginCoordinator();
var runs = 0;
var a = coordinator.EnsureLoggedInAsync(_ => { runs++; return Task.FromResult(LoginOutcome.Failed("no code was entered")); }, CancellationToken.None);
Assert.Equal("no code was entered", (await a).Error);
var b = await coordinator.EnsureLoggedInAsync(_ => { runs++; return Task.FromResult(LoginOutcome.Ok); }, CancellationToken.None);
Assert.True(b.Success);
Assert.Equal(2, runs);
}
[Fact]
public async Task A_cancelled_waiter_does_not_cancel_the_flow()
{
var coordinator = new ClaudeLoginCoordinator();
var gate = new TaskCompletionSource<LoginOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
CancellationToken flowToken = default;
Func<CancellationToken, Task<LoginOutcome>> flow = ct => { flowToken = ct; return gate.Task; };
using var cts = new CancellationTokenSource();
var cancelled = coordinator.EnsureLoggedInAsync(flow, cts.Token);
var other = coordinator.EnsureLoggedInAsync(flow, CancellationToken.None);
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => cancelled);
Assert.False(flowToken.IsCancellationRequested);
gate.SetResult(LoginOutcome.Ok);
Assert.True((await other).Success);
}
[Fact]
public async Task A_throwing_flow_becomes_a_failed_outcome()
{
var coordinator = new ClaudeLoginCoordinator();
var outcome = await coordinator.EnsureLoggedInAsync(_ => throw new InvalidOperationException("boom"), CancellationToken.None);
Assert.False(outcome.Success);
Assert.Contains("boom", outcome.Error);
}
}
```
- [ ] **Step 2: Run; compile failure.**
- [ ] **Step 3: Implement**
```csharp
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>
/// Keeps Claude Code logins single-flight: every session shares one <c>claude</c> configuration, so concurrent expired
/// requests wait on the same flow. The flow runs detached from any caller's token; a caller's cancellation only stops
/// its own wait. The slot clears when the flow completes, so a later expiry starts a fresh one.
/// </summary>
public sealed class ClaudeLoginCoordinator
{
public static ClaudeLoginCoordinator Shared { get; } = new();
private readonly Lock _lock = new();
private Task<LoginOutcome>? _current;
public Task<LoginOutcome> EnsureLoggedInAsync(Func<CancellationToken, Task<LoginOutcome>> runFlow, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(runFlow);
Task<LoginOutcome> flow;
lock (_lock)
{
flow = _current ??= StartAsync(runFlow);
}
return flow.WaitAsync(cancellationToken);
}
private async Task<LoginOutcome> StartAsync(Func<CancellationToken, Task<LoginOutcome>> runFlow)
{
await Task.Yield(); // assign _current before the flow body runs
try
{
return await runFlow(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
return LoginOutcome.Failed(ex.Message);
}
finally
{
lock (_lock) _current = null;
}
}
}
```
If the repo's target framework lacks `System.Threading.Lock` (it's .NET 9+; the repo targets net10.0), `Lock` is fine.
- [ ] **Step 4: Run the tests; pass.**
- [ ] **Step 5: Commit** `git commit -m "feat(claude-code): single-flight login coordinator"`
---
### Task 4: Provider retries after login; `UiLoginPrompt`; options plumbing
**Files:**
- Create: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/UiLoginPrompt.cs`
- Modify: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs`
- Modify: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeProviderExtension.cs`
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeCodeApiTests.cs`, plus `UiLoginPromptTests.cs`
- [ ] **Step 1: `UiLoginPrompt` test (new file `UiLoginPromptTests.cs`).** A small recording `IUserInterface`:
```csharp
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
internal sealed class RecordingUi(params string?[] inputs) : IUserInterface
{
private int _next;
public List<(string Message, NotifyKind Kind)> Notes { get; } = [];
public List<string> Questions { get; } = [];
public Task<string?> SelectAsync(string t, IReadOnlyList<string> o, UiRequestOptions? r, CancellationToken c) => Task.FromResult<string?>(null);
public Task<bool> ConfirmAsync(string t, string m, UiRequestOptions? r, CancellationToken c) => Task.FromResult(false);
public Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? r, CancellationToken c)
{
Questions.Add(title);
return Task.FromResult(_next < inputs.Length ? inputs[_next++] : null);
}
public void Notify(string message, NotifyKind kind = NotifyKind.Info) => Notes.Add((message, kind));
public void SetStatus(string key, string? text) { }
}
public class UiLoginPromptTests
{
[Fact]
public async Task Sends_the_link_then_asks_for_the_code()
{
var ui = new RecordingUi("abc");
var code = await new UiLoginPrompt(ui).AskForCodeAsync("https://x/oauth/authorize?s=1", 1, CancellationToken.None);
Assert.Equal("abc", code);
Assert.Contains("https://x/oauth/authorize?s=1", ui.Notes.Single().Message);
Assert.Equal(["Paste the Claude login code"], ui.Questions);
}
}
```
Implement `UiLoginPrompt`:
```csharp
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";
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);
}
```
- [ ] **Step 2: Options.** In `ClaudeCodeApiOptions` add:
```csharp
/// <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; }
public ClaudeLoginOptions? LoginOptions { get; init; }
```
In `ClaudeCodeProviderOptions` add the same three properties, with the same docs. In `ClaudeCodeProviderExtension.ActivateAsync`, pass them into `apiOptions` (`Coordinator = _options.Coordinator ?? ClaudeLoginCoordinator.Shared`).
- [ ] **Step 3: Failing provider tests** in `ClaudeCodeApiTests`. Extend the `Api(...)` helper with optional `loginUi` and `coordinator` parameters (always pass `new ClaudeLoginCoordinator()` in tests so there's no shared state). The fake claude has to act differently per call. Use a state dir: a `-p` request emits `auth-expired` until `$STATE/logged-in` exists, and `tool-ok` after that. `auth login` prints the URL, reads the code and touches `logged-in`. `auth status` reports logged in when `logged-in` exists. `FakeClaude(body)` already injects the ready-marker prelude, so `body` can be:
```sh
STATE="<root>/state"; mkdir -p "$STATE"
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then
echo "If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?state=x"
printf "Paste code here if prompted > "; read code; : > "$STATE/logged-in"; exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
if [ -f "$STATE/logged-in" ]; then echo '{"loggedIn":true,"authMethod":"claude.ai"}'; else echo '{"loggedIn":false}'; fi; exit 0
fi
echo run >> "$STATE/runs"
if [ -f "$STATE/logged-in" ] && [ ! -f "$STATE/expire-always" ]; then cat "<tool-ok fixture>"; else cat "<auth-expired fixture>"; fi
```
(For `auth` subcommands the prelude only looks for `--system-prompt-file`, which is harmless. Make sure `exit` in the auth branches runs before the helper's `tail`.) Tests:
```csharp
[Fact]
public async Task Expired_session_logs_in_through_the_login_ui_and_replays()
{
if (!Fixtures.IsPosix) return;
var notes = new List<string>();
_registry.Register("session-1", _cwd, (m, _) => notes.Add(m));
var owner = new RecordingUi("the-code");
var api = Api(ExpiringClaude(), loginUi: _ => Task.FromResult<IUserInterface?>(owner));
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
Assert.IsType<AssistantMessageEvent.Done>(events[^1]);
Assert.Equal(2, File.ReadAllLines(Path.Combine(_root, "state", "runs")).Length);
Assert.Contains(notes, n => n.Contains("waiting for the bot owner"));
Assert.Contains(owner.Notes, n => n.Message.Contains("oauth/authorize"));
Assert.Contains(owner.Notes, n => n.Message.Contains("login complete"));
}
[Fact]
public async Task Expired_again_after_login_is_reported_without_a_loop()
{
if (!Fixtures.IsPosix) return;
Directory.CreateDirectory(Path.Combine(_root, "state"));
File.WriteAllText(Path.Combine(_root, "state", "expire-always"), "");
var api = Api(ExpiringClaude(), loginUi: _ => Task.FromResult<IUserInterface?>(new RecordingUi("code")));
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
var error = Assert.IsType<AssistantMessageEvent.Error>(events[^1]); // use the real failure event type name
Assert.Contains("[auth]", error.Message.ErrorMessage);
Assert.Equal(2, File.ReadAllLines(Path.Combine(_root, "state", "runs")).Length);
}
[Fact]
public async Task Without_a_login_ui_the_expiry_is_reported_with_instructions()
{
if (!Fixtures.IsPosix) return;
var api = Api(ExpiringClaude());
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
// Assert the error message contains "claude auth login" and "[auth]", and runs == 1.
}
[Fact]
public async Task Unanswered_login_fails_the_request()
{
if (!Fixtures.IsPosix) return;
var api = Api(ExpiringClaude(), loginUi: _ => Task.FromResult<IUserInterface?>(new RecordingUi()));
// Assert the error contains "Claude Code login failed: no code was entered".
}
```
Look at how existing tests assert failures (the `Fail` event type and message property names in `AssistantMessageEvent`) and use those exact names. `RecordingUi` is `internal` in the same test assembly.
- [ ] **Step 4: Run; failing.**
- [ ] **Step 5: Implement the retry in `ClaudeCodeApi`.** Rename the current `RunRequestAsync` body into `RunAttemptAsync(..., bool publishFailure)` and have it **return** the exception instead of publishing it when `publishFailure == false` and the exception is a `LoginRequired` `ClaudeCodeException` and **no event has been written yet**. Track a local `var wroteAny = false;`, set to true around every `writer.WriteAsync`. Keep the `finally` (process dispose, temp cleanup, notices) as is, but move `writer.TryComplete()` out to the new outer method. Shape:
```csharp
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: true, ct).ConfigureAwait(false);
if (expired is null) return;
var builder = new AssistantMessageBuilder(model);
if (loginUi is null)
{
await writer.WriteAsync(builder.Fail(new ClaudeCodeException(ClaudeCodeException.Codes.Auth,
"Claude Code session expired; run \"claude auth login\" on the host (or /claude-code-login from a chat UI)").FormatForUser()), CancellationToken.None).ConfigureAwait(false);
return;
}
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)
{
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)
{
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) { /* advisory */ }
}
```
`RunAttemptAsync` returns `ClaudeCodeException?`. In its `catch`: if `deferLoginFailure && !wroteAny && !aborted && ex is ClaudeCodeException { LoginRequired: true } login`, return `login` without publishing. Otherwise keep the existing publish path. The `builder.IsFinished` check still applies. Make sure the "Owner could not be reached" path works when `InputAsync` returns null because sending failed: `ClaudeLogin` already maps that to "no code was entered". That's acceptable, and the spec table's "owner unreachable" wording covers the `ui is null` case.
Also check that the `StreamEventMapper` deferred-failure path (`mapper.DeferredError` → `ProtocolStream`) can't swallow the `LoginRequired` exception.
- [ ] **Step 6: Run all `ClaudeCode` tests; all pass (existing ones unchanged).**
- [ ] **Step 7: Commit** `git commit -m "feat(claude-code): pause an expired request, log in through the host UI and replay it"`
---
### Task 5: `/claude-code-login` command and doctor hint
**Files:**
- Create: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/LoginCommand.cs`
- Modify: `ClaudeCodeProviderExtension.cs` (register), `DoctorCommand.cs` (line with `run \`claude auth login\``)
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/LoginCommandTests.cs` and the doctor test that asserts the old wording (update it)
- [ ] **Step 1: Failing test.** Look at `DoctorCommandTests` / `ProviderExtensionTests` for how a command handler is invoked with a fake `IExtensionApi` / command context (`FakeExtensionApi` exists in `tests/.../OpenAI/FakeExtensionApi.cs`). Write:
- `Without_ui_the_command_explains_it_needs_one`: invoke the handler with a context whose `HasUi` is false. Assert that `SendMessage` got a custom message containing "needs an interactive UI".
- `With_ui_it_runs_the_login_through_that_ui`: a fake claude as in Task 2 (the login/status branches only), a `RecordingUi("good")` as the context UI. Assert `Notes` contains "login complete".
- [ ] **Step 2: Implement**
```csharp
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary><c>/claude-code-login</c>: signs the <c>claude</c> CLI in (again) through the current session's UI.</summary>
public static class LoginCommand
{
public const string Name = "claude-code-login";
public const string Description = "Sign Claude Code in to the Claude subscription (link + pasted code)";
public static CommandDefinition Create(IExtensionApi api, ClaudeCodeApi claude)
{
ArgumentNullException.ThrowIfNull(api);
ArgumentNullException.ThrowIfNull(claude);
return new CommandDefinition(Description, async (_, ctx, ct) =>
{
if (!ctx.HasUi)
{
api.SendMessage(CustomMessage.FromText(Name, $"/{Name} needs an interactive UI; run \"claude auth login\" on the host instead."),
MessageDelivery.Steer, triggerTurn: false);
return;
}
var ui = ctx.Ui;
await (claude.Options.Coordinator ?? ClaudeLoginCoordinator.Shared)
.EnsureLoggedInAsync(flowCt => claude.RunLoginFlowAsync(_ => Task.FromResult<Abstractions.Ui.IUserInterface?>(ui), flowCt), ct)
.ConfigureAwait(false);
});
}
}
```
Match the real `CommandDefinition` handler signature (see `DoctorCommand.Create`). Register it in `ActivateAsync` after the doctor: `api.RegisterCommand(LoginCommand.Name, LoginCommand.Create(api, claude));`.
Doctor: change the line to ``"- Auth: **not logged in**; run `/claude-code-login` (or `claude auth login` on the host) with a Claude subscription"`` and update any test asserting the old text.
- [ ] **Step 3: Run the extension tests; all pass.**
- [ ] **Step 4: Commit** `git commit -m "feat(claude-code): /claude-code-login command"`
---
### Task 6: Telegram host — the owner receives the link
**Files:**
- Create: `hosts/Volcanic.AgenticCore.Telegram/Chats/OwnerLoginChannel.cs`
- Modify: `hosts/Volcanic.AgenticCore.Telegram/Program.cs`
- Test: `tests/Volcanic.AgenticCore.Telegram.Tests/OwnerLoginTests.cs`
- Docs: `docs/claude-code-provider.md`, `docs/telegram-bot.md`
- [ ] **Step 1: Failing test.** Using `ChatHost` (Support/ChatHost.cs) and `FakeBotClient`:
```csharp
[Fact]
public async Task Login_link_and_code_prompt_go_to_the_owner_chat_and_the_reply_answers_it()
{
await using var host = new ChatHost();
var channel = new OwnerLoginChannel(host.Chats, host.Options);
var ui = await channel.ResolveAsync(CancellationToken.None);
Assert.NotNull(ui);
var ask = new UiLoginPrompt(ui!).AskForCodeAsync("https://claude.com/cai/oauth/authorize?state=t", 1, CancellationToken.None);
// wait until the ForceReply question has been sent to Updates.AllowedUser's chat (inspect FakeBotClient's sent messages; poll briefly)
await host.SendAsync("pasted-code"); // plain text from the owner answers the pending InputAsync
Assert.Equal("pasted-code", await ask.WaitAsync(TimeSpan.FromSeconds(5)));
// assert FakeBotClient recorded a message to chat Updates.AllowedUser containing "oauth/authorize"
}
```
Look at `FakeBotClient` and `ChatTests` for how sent messages are inspected and how the tests wait for rendered output (for example `SettleAsync`), and use those helpers. The notification goes through `ChatRenderer.Post`, which is asynchronous, so wait for it the same way the existing notification tests do.
- [ ] **Step 2: Implement**
```csharp
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// The Claude Code login channel: the private chat of the first allowed user (the bot owner). In a private chat the chat
/// id is the user id, so the chat is created on demand even if the owner has not written yet (sending then fails
/// unless the owner has started the bot, which surfaces as an unanswered login).
/// </summary>
public sealed class OwnerLoginChannel(ChatRegistry chats, TelegramOptions options)
{
public async Task<IUserInterface?> ResolveAsync(CancellationToken cancellationToken)
{
if (options.AllowedUserIds.Count == 0) return null;
return (await chats.GetAsync(options.AllowedUserIds[0], cancellationToken).ConfigureAwait(false)).Ui;
}
}
```
`Program.cs`:
```csharp
builder.Services.AddSingleton<OwnerLoginChannel>();
// The extension is constructed per session through ActivatorUtilities, which injects this; LoginUi resolves lazily to
// avoid a construction cycle (ChatRegistry -> session factory -> extensions).
builder.Services.AddSingleton(sp => new ClaudeCodeProviderOptions
{
LoginUi = ct => sp.GetRequiredService<OwnerLoginChannel>().ResolveAsync(ct),
});
```
Check that `ActivatorUtilities.CreateInstance` picks the constructor with the optional parameter when the service is registered. It does when the type is resolvable, but confirm with a quick test or by reading `ExtensionRegistration`. If it doesn't, register the extension with `o.Extensions.Add(ExtensionRegistration.FromFactory(...))` using the options instance.
- [ ] **Step 3: Docs.** In `docs/claude-code-provider.md`, add a "Session expiry and re-login" section: detection, pause-and-replay, single flight, `LoginUi`, the headless message, `/claude-code-login`, and 3 attempts / 10-minute prompt. In `docs/telegram-bot.md`, say that the first allowed user gets login links and must have started the bot.
- [ ] **Step 4: Run the full suite** `dotnet test VolcanicAgenticCore.slnx`; everything passes. `dotnet build` shows no new warnings (the repo may use TreatWarningsAsErrors via Directory.Build.props).
- [ ] **Step 5: Commit** `git commit -m "feat(telegram): send Claude Code login links to the bot owner"`