Files
VolcanicAgenticCore/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeProviderExtension.cs

100 lines
4.5 KiB
C#

using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>Host overrides for the extension; everything is located automatically when null.</summary>
public sealed record ClaudeCodeProviderOptions
{
public string? ClaudePath { get; init; }
public string? BridgeDllPath { get; init; }
public string? DotnetPath { get; init; }
public Func<string, string?>? GetEnv { get; init; }
/// <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>
/// Registers the <c>claude-code</c> provider (driving the local <c>claude</c> CLI on a subscription), the
/// <c>/claude-code-doctor</c> command, and the session bookkeeping the provider resolves working directories from.
/// The provider is registered even when the CLI is missing so the doctor can explain what to fix; requests then fail
/// with <c>executable_missing</c>.
/// </summary>
public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? options = null) : IExtension
{
public const string ExtensionName = "claude-code-provider";
private readonly ClaudeCodeProviderOptions _options = options ?? new ClaudeCodeProviderOptions();
private string? _ownSessionId;
public string Name => ExtensionName;
/// <summary>The API instance created during activation.</summary>
public ClaudeCodeApi? Api { get; private set; }
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(api);
var getEnv = _options.GetEnv ?? Environment.GetEnvironmentVariable;
var registry = _options.Registry ?? SessionRegistry.Shared;
var apiOptions = new ClaudeCodeApiOptions
{
ClaudePath = _options.ClaudePath ?? ClaudeInstallation.Locate(getEnv),
BridgeDllPath = _options.BridgeDllPath ?? BridgeLocator.FindBridgeDll(),
DotnetPath = _options.DotnetPath ?? BridgeLocator.FindDotnet(getEnv),
GetEnv = getEnv,
ServerName = _options.ServerName,
};
var claude = new ClaudeCodeApi(registry, apiOptions);
Api = claude;
api.RegisterProvider(new ProviderRegistration
{
Id = ClaudeCodeModels.ProviderId,
Name = ClaudeCodeModels.ProviderName,
Api = ClaudeCodeModels.ApiId,
BaseUrl = ClaudeCodeModels.BaseUrl,
RequiresApiKey = false,
Models = ClaudeCodeModels.All,
Stream = claude.StreamAsync,
});
api.RegisterCommand(DoctorCommand.Name, DoctorCommand.Create(api, apiOptions, registry));
api.On<SessionStartEvent>((_, ctx, _) =>
{
// Idempotent: a session can start twice without a shutdown between (reload, resume); drop the earlier id
// so the second registration does not orphan the first.
if (_ownSessionId is not null && _ownSessionId != ctx.SessionId) registry.Remove(_ownSessionId);
_ownSessionId = ctx.SessionId;
registry.Register(ctx.SessionId, ctx.Cwd, ctx.HasUi ? ctx.Ui.Notify : null);
if (apiOptions.ClaudePath is null && ctx.HasUi)
ctx.Ui.Notify($"[claude-code] Claude Code (claude) was not found; run /{DoctorCommand.Name} for details", NotifyKind.Warning);
return ValueTask.CompletedTask;
});
api.On<SessionShutdownEvent>((_, ctx, _) =>
{
registry.Remove(ctx.SessionId);
if (_ownSessionId == ctx.SessionId) _ownSessionId = null;
return ValueTask.CompletedTask;
});
api.On<MessageEndEvent>((e, _, _) =>
{
// Claude's own overflow wording is prefixed so the core compacts and retries instead of surfacing an error.
if (e.Message is AssistantMessage { StopReason: StopReason.Error, ErrorMessage: { } error } assistant)
{
var normalized = StreamEventMapper.NormalizeOverflowMessage(error);
if (normalized != error) e.Message = assistant with { ErrorMessage = normalized };
}
return ValueTask.CompletedTask;
});
return ValueTask.CompletedTask;
}
}