diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Program.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Program.cs
index 51bb64e..1cb88f5 100644
--- a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Program.cs
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Program.cs
@@ -1 +1,215 @@
-return 0;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge;
+
+///
+/// Proposal-only MCP stdio server. It lists the tool catalogue the provider wrote for it and refuses
+/// every tools/call: Claude Code's dontAsk mode denies the call itself, and the provider
+/// captures the model's tool_use block as a proposal for the core to execute.
+/// Line-delimited JSON-RPC 2.0 over stdio; a request over is fatal so
+/// rejected framing state is never reused.
+///
+public static class Bridge
+{
+ public const int MaxRequestBytes = 1024 * 1024;
+ public const string CatalogVariable = "VOLCANIC_CLAUDE_TOOL_CATALOG";
+ public const string ReadyVariable = "VOLCANIC_CLAUDE_TOOL_READY";
+ public const string ViolationVariable = "VOLCANIC_CLAUDE_TOOL_VIOLATION";
+ public const string ServerName = "volcanic-tool-proposals";
+ public const string ServerVersion = "1.0.0";
+ public const string ProtocolVersion = "2025-11-25";
+ public const string ExecutionRefused = "Security invariant: this server proposes tools but never executes them";
+
+ public static int Main()
+ {
+ var env = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables())
+ env[(string)entry.Key] = entry.Value as string;
+ var utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
+ using var input = new StreamReader(Console.OpenStandardInput(), utf8, detectEncodingFromByteOrderMarks: false);
+ using var output = new StreamWriter(Console.OpenStandardOutput(), utf8) { AutoFlush = true, NewLine = "\n" };
+ using var error = new StreamWriter(Console.OpenStandardError(), utf8) { AutoFlush = true, NewLine = "\n" };
+ return Run(input, output, error, env);
+ }
+
+ /// Serves requests from until EOF. Returns 0 on EOF, 1 on a fatal request, 2 on a startup failure.
+ public static int Run(TextReader input, TextWriter output, TextWriter error, IReadOnlyDictionary env)
+ {
+ if (!env.TryGetValue(CatalogVariable, out var catalogPath) || string.IsNullOrWhiteSpace(catalogPath))
+ return FailStartup(error, $"{CatalogVariable} is required");
+
+ JsonArray tools;
+ try
+ {
+ var node = JsonNode.Parse(File.ReadAllText(catalogPath));
+ tools = node as JsonArray ?? throw new InvalidDataException("catalog must be an array");
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException or NotSupportedException)
+ {
+ return FailStartup(error, $"invalid tool catalog: {ex.Message}");
+ }
+
+ env.TryGetValue(ReadyVariable, out var readyPath);
+ env.TryGetValue(ViolationVariable, out var violationPath);
+ var server = new Server(output, tools, readyPath, violationPath);
+
+ try
+ {
+ var buffer = new char[8192];
+ var pending = new StringBuilder();
+ while (true)
+ {
+ var read = input.Read(buffer, 0, buffer.Length);
+ if (read <= 0) break;
+ var start = 0;
+ for (var i = 0; i < read; i++)
+ {
+ if (buffer[i] != '\n') continue;
+ pending.Append(buffer, start, i - start);
+ start = i + 1;
+ if (!server.AcceptLine(TakeLine(pending))) return 1;
+ }
+ pending.Append(buffer, start, read - start);
+ // Every char is at least one byte, so this many chars can never fit the byte bound (one CR allowed).
+ if (pending.Length > MaxRequestBytes + 1)
+ {
+ server.FailOversized();
+ return 1;
+ }
+ }
+ if (pending.Length > 0 && !server.AcceptLine(TakeLine(pending))) return 1;
+ return 0;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ error.WriteLine($"MCP bridge failure: {ex.Message}");
+ error.Flush();
+ return 1;
+ }
+ }
+
+ private static string TakeLine(StringBuilder pending)
+ {
+ if (pending.Length > 0 && pending[^1] == '\r') pending.Length--;
+ var line = pending.ToString();
+ pending.Clear();
+ return line;
+ }
+
+ private static int FailStartup(TextWriter error, string message)
+ {
+ error.WriteLine(message);
+ error.Flush();
+ return 2;
+ }
+
+ private sealed class Server(TextWriter output, JsonArray tools, string? readyPath, string? violationPath)
+ {
+ private static readonly JsonSerializerOptions WireOptions = new(JsonSerializerDefaults.General);
+
+ /// Handles one line. Returns false when the request was oversized and the server must stop.
+ public bool AcceptLine(string line)
+ {
+ if (Encoding.UTF8.GetByteCount(line) > MaxRequestBytes)
+ {
+ FailOversized();
+ return false;
+ }
+ if (string.IsNullOrWhiteSpace(line)) return true;
+
+ JsonNode? message;
+ try
+ {
+ message = JsonNode.Parse(line);
+ }
+ catch (JsonException)
+ {
+ SendError(null, -32700, "Invalid JSON-RPC request");
+ return true;
+ }
+ if (message is not JsonObject request)
+ {
+ SendError(null, -32600, "Invalid JSON-RPC request");
+ return true;
+ }
+
+ var hasId = request.ContainsKey("id");
+ var id = request["id"];
+ var method = request["method"] is JsonValue value && value.TryGetValue(out var m) ? m : null;
+
+ switch (method)
+ {
+ case "initialize":
+ if (hasId)
+ {
+ Send(new JsonObject
+ {
+ ["jsonrpc"] = "2.0",
+ ["id"] = id?.DeepClone(),
+ ["result"] = new JsonObject
+ {
+ ["protocolVersion"] = ProtocolVersion,
+ ["capabilities"] = new JsonObject { ["tools"] = new JsonObject() },
+ ["serverInfo"] = new JsonObject { ["name"] = ServerName, ["version"] = ServerVersion },
+ },
+ });
+ }
+ return true;
+ case "tools/list":
+ if (hasId)
+ {
+ Send(new JsonObject
+ {
+ ["jsonrpc"] = "2.0",
+ ["id"] = id?.DeepClone(),
+ ["result"] = new JsonObject { ["tools"] = tools.DeepClone() },
+ });
+ }
+ Mark(readyPath, "ready\n");
+ return true;
+ case "tools/call":
+ Mark(violationPath, "tools/call reached proposal-only MCP server\n");
+ if (hasId) SendError(id, -32001, ExecutionRefused);
+ return true;
+ default:
+ // Notifications (no id) such as notifications/initialized are ignored.
+ if (hasId) SendError(id, -32601, $"Method not found: {method ?? "null"}");
+ return true;
+ }
+ }
+
+ public void FailOversized() => SendError(null, -32700, "MCP request exceeds the bridge limit");
+
+ private void SendError(JsonNode? id, int code, string message) => Send(new JsonObject
+ {
+ ["jsonrpc"] = "2.0",
+ ["id"] = id?.DeepClone(),
+ ["error"] = new JsonObject { ["code"] = code, ["message"] = message },
+ });
+
+ private void Send(JsonObject response)
+ {
+ output.Write(response.ToJsonString(WireOptions));
+ output.Write('\n');
+ output.Flush();
+ }
+
+ private static void Mark(string? path, string contents)
+ {
+ if (string.IsNullOrEmpty(path)) return;
+ try
+ {
+ var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
+ if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
+ using var stream = new FileStream(path, options);
+ stream.Write(Encoding.UTF8.GetBytes(contents));
+ }
+ catch (IOException) when (File.Exists(path))
+ {
+ // Marker already written by an earlier request: keep the first one.
+ }
+ }
+ }
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeArgs.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeArgs.cs
new file mode 100644
index 0000000..7217e13
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeArgs.cs
@@ -0,0 +1,248 @@
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+/// Everything the argv builder needs for one request.
+/// The alias passed to --model.
+/// The --effort level, or null to omit the flag.
+/// File handed to --system-prompt-file.
+/// Tool catalogue for the bridge; null when the request carries no tools (empty MCP config).
+/// Marker the bridge creates on a tools/call.
+/// Marker the bridge creates after tools/list.
+/// The bridge assembly dotnet runs.
+/// The dotnet muxer.
+/// The MCP server name; tools are exposed as mcp__<ServerName>__<alias>.
+public sealed record ClaudeArgsInput(
+ string Model,
+ string? Effort,
+ string SystemPromptPath,
+ string? CatalogPath = null,
+ string? ViolationPath = null,
+ string? ReadyPath = null,
+ string? BridgeDllPath = null,
+ string? DotnetPath = null,
+ string ServerName = ToolCatalog.DefaultServerName);
+
+/// Builds the exact claude argv and the allow-listed child environment.
+public static class ClaudeArgs
+{
+ // Empty setting sources plus explicit settings preserve subscription authentication while suppressing user and
+ // project customisations. --bare would disable OAuth, and --safe-mode would disable the proposal MCP server.
+ // totalTokensReminder would otherwise append a changing reminder that breaks append-only cache reuse, and the
+ // built-in agents-md plugin loads regardless of setting sources unless disabled here.
+ public const string Settings = "{\"disableAllHooks\":true,\"autoMemoryEnabled\":false,\"totalTokensReminder\":\"off\",\"enabledPlugins\":{\"agents-md@builtin\":false}}";
+ public const string EmptyMcpConfig = "{\"mcpServers\":{}}";
+
+ public const string CatalogVariable = "VOLCANIC_CLAUDE_TOOL_CATALOG";
+ public const string ViolationVariable = "VOLCANIC_CLAUDE_TOOL_VIOLATION";
+ public const string ReadyVariable = "VOLCANIC_CLAUDE_TOOL_READY";
+ public const string MaxOutputTokensVariable = "CLAUDE_CODE_MAX_OUTPUT_TOKENS";
+
+ private static readonly JsonSerializerOptions ConfigOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
+
+ /// Variables forwarded from the parent environment (compared case-insensitively). LC_* is forwarded as a prefix.
+ public static readonly IReadOnlyList AllowedVariables =
+ [
+ "HOME", "PATH", "USER", "LOGNAME", "SHELL", "LANG", "TMPDIR", "TMP", "TEMP",
+ // A relocated Claude Code configuration directory holds the login to use.
+ "CLAUDE_CONFIG_DIR",
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
+ // The extra CA bundle a TLS-inspecting proxy requires.
+ "NODE_EXTRA_CA_CERTS",
+ "SystemRoot", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "ComSpec", "PATHEXT",
+ ];
+
+ /// Variables that must never reach a Claude child: this provider is subscription-only.
+ public static readonly IReadOnlyList DeniedVariables =
+ [
+ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL",
+ "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX",
+ ];
+
+ /// The complete argv (without the executable), in the fixed order Claude Code is validated against.
+ public static IReadOnlyList Build(ClaudeArgsInput input)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+ if (string.IsNullOrWhiteSpace(input.Model)) throw new ArgumentException("A model alias is required.", nameof(input));
+ if (string.IsNullOrWhiteSpace(input.SystemPromptPath)) throw new ArgumentException("A system prompt file is required.", nameof(input));
+
+ var args = new List
+ {
+ "-p",
+ "--setting-sources", "",
+ "--settings", Settings,
+ "--disable-slash-commands",
+ "--strict-mcp-config",
+ "--permission-mode", "dontAsk",
+ "--no-chrome",
+ "--no-session-persistence",
+ "--prompt-suggestions", "false",
+ "--mcp-config", BuildMcpConfig(input),
+ "--tools", "",
+ "--model", input.Model,
+ };
+ if (input.Effort is not null)
+ {
+ args.Add("--effort");
+ args.Add(input.Effort);
+ }
+ // Claude 5 and Opus 4.7+ return thinking blocks whose text is empty unless summarized display is requested.
+ args.Add("--thinking-display");
+ args.Add("summarized");
+ args.Add("--input-format");
+ args.Add("stream-json");
+ args.Add("--output-format");
+ args.Add("stream-json");
+ args.Add("--include-partial-messages");
+ args.Add("--verbose");
+ // The system prompt stays out of the argv, away from command-line size limits.
+ args.Add("--system-prompt-file");
+ args.Add(input.SystemPromptPath);
+ return args;
+ }
+
+ /// The --mcp-config JSON: the bridge as the only server, or no servers at all without tools.
+ public static string BuildMcpConfig(ClaudeArgsInput input)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+ if (input.CatalogPath is null) return EmptyMcpConfig;
+ if (string.IsNullOrWhiteSpace(input.BridgeDllPath))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, "The proposal MCP bridge assembly was not located; tools cannot be exposed to Claude Code");
+ if (string.IsNullOrWhiteSpace(input.DotnetPath))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, "The dotnet muxer was not located; the proposal MCP bridge cannot be launched");
+ var env = new JsonObject { [CatalogVariable] = input.CatalogPath };
+ if (input.ViolationPath is not null) env[ViolationVariable] = input.ViolationPath;
+ if (input.ReadyPath is not null) env[ReadyVariable] = input.ReadyPath;
+ var config = new JsonObject
+ {
+ ["mcpServers"] = new JsonObject
+ {
+ [input.ServerName] = new JsonObject
+ {
+ ["command"] = input.DotnetPath,
+ ["args"] = new JsonArray(input.BridgeDllPath),
+ ["env"] = env,
+ },
+ },
+ };
+ return config.ToJsonString(ConfigOptions);
+ }
+
+ /// The name of a denied variable present in , or null.
+ public static string? FindDeniedVariable(IReadOnlyDictionary parentEnv)
+ {
+ ArgumentNullException.ThrowIfNull(parentEnv);
+ foreach (var (name, value) in parentEnv)
+ {
+ if (value is null) continue;
+ if (DeniedVariables.Any(denied => string.Equals(denied, name, StringComparison.OrdinalIgnoreCase))) return name;
+ }
+ return null;
+ }
+
+ ///
+ /// The complete environment a Claude child receives: the allow-listed parent variables plus the pinned ones.
+ /// Throws auth when the parent process carries API-key or third-party-provider authentication.
+ ///
+ public static IReadOnlyDictionary BuildEnvironment(IReadOnlyDictionary parentEnv, int maxOutputTokens)
+ {
+ ArgumentNullException.ThrowIfNull(parentEnv);
+ if (FindDeniedVariable(parentEnv) is { } denied)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth,
+ $"{denied} is set; the Claude Code provider only supports subscription authentication and never forwards API-key or third-party credentials. Unset it to use this provider");
+ return AllowListed(parentEnv, maxOutputTokens);
+ }
+
+ /// The allow-listed environment without the denial check (used by diagnostics that only probe the CLI).
+ public static IReadOnlyDictionary AllowListed(IReadOnlyDictionary parentEnv, int? maxOutputTokens)
+ {
+ ArgumentNullException.ThrowIfNull(parentEnv);
+ var env = new Dictionary(StringComparer.Ordinal);
+ foreach (var (name, value) in parentEnv)
+ {
+ if (value is null) continue;
+ var allowed = name.StartsWith("LC_", StringComparison.Ordinal)
+ || AllowedVariables.Any(a => string.Equals(a, name, StringComparison.OrdinalIgnoreCase));
+ if (allowed) env[name] = value;
+ }
+ env["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] = "1";
+ // Claude Code collects git status, log and user.name at startup for a default system prompt this provider
+ // replaces. Run in the user's project, that status can execute a configured git clean filter.
+ env["CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS"] = "1";
+ env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1";
+ // The core owns compaction. Given a transcript near its limit, Claude Code would otherwise compact it itself.
+ env["DISABLE_COMPACT"] = "1";
+ if (maxOutputTokens is { } tokens) env[MaxOutputTokensVariable] = tokens.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ return env;
+ }
+
+ /// The current process environment as a dictionary.
+ public static IReadOnlyDictionary CurrentEnvironment()
+ {
+ var env = new Dictionary(StringComparer.Ordinal);
+ foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables())
+ env[(string)entry.Key] = entry.Value as string;
+ return env;
+ }
+}
+
+/// Finds the bridge assembly and the dotnet muxer that runs it.
+public static class BridgeLocator
+{
+ public const string BridgeFileName = "Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll";
+
+ /// The bridge dll next to the host, else next to this assembly; null when neither exists.
+ public static string? FindBridgeDll()
+ {
+ var candidates = new List { Path.Combine(AppContext.BaseDirectory, BridgeFileName) };
+ var assemblyDirectory = SafeDirectory(typeof(BridgeLocator).Assembly.Location);
+ if (assemblyDirectory is not null) candidates.Add(Path.Combine(assemblyDirectory, BridgeFileName));
+ return candidates.FirstOrDefault(File.Exists);
+ }
+
+ /// The dotnet muxer: the current process when it is dotnet, else dotnet on PATH, else DOTNET_ROOT/dotnet.
+ public static string? FindDotnet(Func getEnv)
+ {
+ ArgumentNullException.ThrowIfNull(getEnv);
+ var processPath = Environment.ProcessPath;
+ if (processPath is not null)
+ {
+ var name = Path.GetFileNameWithoutExtension(processPath);
+ if (string.Equals(name, "dotnet", StringComparison.OrdinalIgnoreCase) && File.Exists(processPath)) return processPath;
+ }
+ var names = OperatingSystem.IsWindows() ? new[] { "dotnet.exe" } : new[] { "dotnet" };
+ foreach (var directory in (getEnv("PATH") ?? "").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
+ {
+ foreach (var name in names)
+ {
+ var candidate = Path.Combine(directory, name);
+ if (File.Exists(candidate)) return candidate;
+ }
+ }
+ var root = getEnv("DOTNET_ROOT");
+ if (!string.IsNullOrWhiteSpace(root))
+ {
+ foreach (var name in names)
+ {
+ var candidate = Path.Combine(root, name);
+ if (File.Exists(candidate)) return candidate;
+ }
+ }
+ return null;
+ }
+
+ private static string? SafeDirectory(string location)
+ {
+ if (string.IsNullOrEmpty(location)) return null;
+ try
+ {
+ return Path.GetDirectoryName(location);
+ }
+ catch (ArgumentException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs
new file mode 100644
index 0000000..cb2bafd
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs
@@ -0,0 +1,314 @@
+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;
+
+/// Where the CLI, the bridge and the muxer live, plus environment access (overridable for tests).
+public sealed record ClaudeCodeApiOptions
+{
+ /// The claude executable; null when it was not located (every request then fails with executable_missing).
+ public string? ClaudePath { get; init; }
+
+ /// The proposal bridge assembly; null when it was not located (tool-bearing requests then fail with mcp_startup).
+ public string? BridgeDllPath { get; init; }
+
+ /// The dotnet muxer that runs the bridge.
+ public string? DotnetPath { get; init; }
+
+ /// Environment lookup for the timeout settings.
+ public Func GetEnv { get; init; } = Environment.GetEnvironmentVariable;
+
+ /// The environment the allow-list is applied to; the process environment when null.
+ public IReadOnlyDictionary? ParentEnvironment { get; init; }
+
+ /// The MCP server name the bridge is registered under (tools become mcp__<name>__<alias>).
+ public string ServerName { get; init; } = ToolCatalog.DefaultServerName;
+}
+
+///
+/// The claude-code-headless API: fully stateless. Every request spawns a fresh claude -p, replays the whole
+/// transcript as one stream-json user message, exposes the active tools through the proposal bridge, captures the
+/// model's tool_use as a proposal (terminating Claude at the stop), and never throws out of .
+///
+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);
+ private static readonly TimeSpan ReadyPollInterval = TimeSpan.FromMilliseconds(25);
+ private static readonly TimeSpan ExitGrace = TimeSpan.FromSeconds(10);
+ private static readonly IReadOnlyDictionary NoHeaders = new Dictionary();
+ 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 StreamAsync(
+ Model model,
+ LlmContext context,
+ StreamOptions streamOptions,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var channel = Channel.CreateUnbounded(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 writer, CancellationToken ct)
+ {
+ var builder = new AssistantMessageBuilder(model);
+ StreamEventMapper? mapper = null;
+ ClaudeProcess? process = null;
+ string? tempDir = null;
+ string? violationPath = null;
+ Action? notify = null;
+ var terminatedForHandoff = false;
+ var responseAnnounced = 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));
+ var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens);
+
+ 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;
+ 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);
+ 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.
+ 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)}");
+ done = mapper.Complete();
+ }
+ 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 writer.WriteAsync(done, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ var aborted = ct.IsCancellationRequested || ex is OperationCanceledException;
+ if (process is not null) await process.TerminateAsync().ConfigureAwait(false);
+ 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);
+ }
+ 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 */ }
+ }
+ }
+ writer.TryComplete();
+ }
+ }
+
+ private static async Task WaitForReadyAsync(string readyPath, TimeSpan timeout, ClaudeProcess process, IReadOnlyList 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 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 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 static string FormatFailure(Exception ex) => ex switch
+ {
+ 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;
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeException.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeException.cs
new file mode 100644
index 0000000..17758e9
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeException.cs
@@ -0,0 +1,36 @@
+namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+/// A typed failure of the Claude Code transport. is stable and machine-readable.
+public sealed class ClaudeCodeException(string code, string message, bool retryable = false) : Exception(message)
+{
+ public string Code { get; } = code;
+
+ /// True when repeating the request from the unchanged context can succeed (a mid-stream interruption).
+ public bool Retryable { get; } = retryable;
+
+ ///
+ /// The message the provider publishes: [code] message, except that a context overflow keeps its
+ /// context_length_exceeded: prefix intact because the core's retry policy keys on it.
+ ///
+ public string FormatForUser() => Code == Codes.ContextOverflow ? Message : $"[{Code}] {Message}";
+
+ public static class Codes
+ {
+ public const string ExecutableMissing = "executable_missing";
+ public const string Auth = "auth";
+ public const string ProtocolInit = "protocol_init";
+ public const string ProtocolStream = "protocol_stream";
+ public const string McpStartup = "mcp_startup";
+ public const string McpExecution = "mcp_execution";
+ public const string ToolUnknown = "tool_unknown";
+ public const string StreamInterrupted = "stream_interrupted";
+ public const string WorkingDirectory = "working_directory";
+ public const string ProcessExit = "process_exit";
+ public const string Timeout = "timeout";
+ public const string ContextOverflow = "context_overflow";
+ /// The context or tool declarations handed to the provider had a shape it cannot serialise.
+ public const string ContentShape = "content_shape";
+ /// An image could not be attached (type, encoding, size, count or path).
+ public const string Image = "image";
+ }
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeModels.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeModels.cs
new file mode 100644
index 0000000..a2d31fb
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeModels.cs
@@ -0,0 +1,58 @@
+using Volcanic.AgenticCore.Abstractions.Providers;
+
+namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+///
+/// The model aliases Claude Code serves on a subscription. Cost is zero (the subscription pays), the
+/// context windows are Claude Code's own, and MaxTokens is Claude Code's default output cap per alias.
+///
+public static class ClaudeCodeModels
+{
+ public const string ProviderId = "claude-code";
+ public const string ApiId = "claude-code-headless";
+ public const string BaseUrl = "claude-code://local";
+ public const string ProviderName = "Claude Code (subscription)";
+
+ /// Thinking level to --effort value. null omits the flag and keeps Claude Code's default.
+ public static readonly IReadOnlyDictionary EffortLevels = new Dictionary
+ {
+ [ThinkingLevel.Off] = null,
+ [ThinkingLevel.Minimal] = null,
+ [ThinkingLevel.Low] = "low",
+ [ThinkingLevel.Medium] = "medium",
+ [ThinkingLevel.High] = "high",
+ [ThinkingLevel.XHigh] = "xhigh",
+ [ThinkingLevel.Max] = "max",
+ };
+
+ public static readonly Model Sonnet = Create("sonnet", "Claude Code Sonnet", 1_000_000, 64_000, reasoning: true);
+ public static readonly Model Opus = Create("opus", "Claude Code Opus", 1_000_000, 128_000, reasoning: true);
+ // Haiku has no effort control; Claude Code still owns its thinking default.
+ public static readonly Model Haiku = Create("haiku", "Claude Code Haiku", 200_000, 32_000, reasoning: false);
+ public static readonly Model Fable = Create("fable", "Claude Code Fable", 1_000_000, 64_000, reasoning: true);
+
+ public static IReadOnlyList All { get; } = [Sonnet, Opus, Haiku, Fable];
+
+ /// The --effort value for a request, or null to omit the flag.
+ public static string? EffortFor(Model model, ThinkingLevel? level)
+ {
+ if (!model.Reasoning || level is null) return null;
+ var map = model.ThinkingLevelMap ?? EffortLevels;
+ return map.TryGetValue(level.Value, out var effort) ? effort : null;
+ }
+
+ private static Model Create(string id, string name, int contextWindow, int maxTokens, bool reasoning) => new()
+ {
+ Id = id,
+ Name = name,
+ Provider = ProviderId,
+ Api = ApiId,
+ BaseUrl = BaseUrl,
+ Reasoning = reasoning,
+ Input = [ModelInput.Text, ModelInput.Image],
+ Cost = ModelCost.Zero,
+ ContextWindow = contextWindow,
+ MaxTokens = maxTokens,
+ ThinkingLevelMap = reasoning ? EffortLevels : null,
+ };
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeProviderExtension.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeProviderExtension.cs
new file mode 100644
index 0000000..9fb1a45
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeProviderExtension.cs
@@ -0,0 +1,99 @@
+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;
+
+/// Host overrides for the extension; everything is located automatically when null.
+public sealed record ClaudeCodeProviderOptions
+{
+ public string? ClaudePath { get; init; }
+ public string? BridgeDllPath { get; init; }
+ public string? DotnetPath { get; init; }
+ public Func? GetEnv { get; init; }
+ /// The registry to use; the process-wide when null.
+ public SessionRegistry? Registry { get; init; }
+ public string ServerName { get; init; } = ToolCatalog.DefaultServerName;
+}
+
+///
+/// Registers the claude-code provider (driving the local claude CLI on a subscription), the
+/// /claude-code-doctor 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 executable_missing.
+///
+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;
+
+ /// The API instance created during activation.
+ 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((_, 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((_, ctx, _) =>
+ {
+ registry.Remove(ctx.SessionId);
+ if (_ownSessionId == ctx.SessionId) _ownSessionId = null;
+ return ValueTask.CompletedTask;
+ });
+
+ api.On((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;
+ }
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeImageStore.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeImageStore.cs
new file mode 100644
index 0000000..e12e1d9
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeImageStore.cs
@@ -0,0 +1,116 @@
+using System.Security.Cryptography;
+using Volcanic.AgenticCore.Abstractions.Messages;
+
+namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+/// An image written to the store: the file name referenced by image_attachment blocks and the absolute path Claude reads.
+public sealed record ImageAttachment(string FileName, string AbsolutePath, int Bytes, bool Written);
+
+///
+/// Per-session, content-addressed image files (image-<sha256>.<ext>) that Claude Code reads through
+/// quoted @"path" references. Paths stay stable across requests so the replayed transcript prefix stays cacheable.
+///
+public sealed class ClaudeImageStore : IDisposable
+{
+ public const int MaxImageBytes = 20 * 1024 * 1024;
+ public const long MaxTotalImageBytes = 100L * 1024 * 1024;
+ public const int MaxImagesPerRequest = 20;
+
+ private static readonly IReadOnlyDictionary Extensions = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["image/png"] = "png",
+ ["image/jpeg"] = "jpg",
+ ["image/gif"] = "gif",
+ ["image/webp"] = "webp",
+ };
+
+ private readonly Lock _lock = new();
+ private readonly string? _temporaryRoot;
+ private string? _directory;
+ private bool _disposed;
+
+ /// Where the store directory is created; the system temp directory when null.
+ public ClaudeImageStore(string? temporaryRoot = null)
+ {
+ _temporaryRoot = temporaryRoot;
+ }
+
+ /// The store directory, once the first image was written.
+ public string? Directory => _directory;
+
+ public static bool IsSupportedMimeType(string mimeType) => Extensions.ContainsKey(mimeType);
+
+ /// Validates and writes one image (a second copy of identical bytes is not rewritten).
+ public ImageAttachment Add(ImageContent image)
+ {
+ ArgumentNullException.ThrowIfNull(image);
+ if (string.IsNullOrEmpty(image.MimeType) || !Extensions.TryGetValue(image.MimeType, out var extension))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, $"Unsupported image type: {image.MimeType}");
+ byte[] bytes;
+ try
+ {
+ bytes = Convert.FromBase64String(image.Data ?? "");
+ }
+ catch (FormatException)
+ {
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, "Image data is not valid base64");
+ }
+ if (bytes.Length == 0 || bytes.Length > MaxImageBytes)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, $"Image size must be between 1 byte and {MaxImageBytes} bytes");
+
+ var digest = Convert.ToHexStringLower(SHA256.HashData(bytes));
+ var fileName = $"image-{digest}.{extension}";
+ lock (_lock)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ var directory = EnsureDirectory();
+ var path = Path.Combine(directory, fileName);
+ if (File.Exists(path))
+ {
+ if (!File.ReadAllBytes(path).AsSpan().SequenceEqual(bytes))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, "Image store contains mismatched image bytes");
+ return new ImageAttachment(fileName, path, bytes.Length, Written: false);
+ }
+ var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
+ if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
+ using (var stream = new FileStream(path, options)) stream.Write(bytes);
+ return new ImageAttachment(fileName, path, bytes.Length, Written: true);
+ }
+ }
+
+ private string EnsureDirectory()
+ {
+ if (_directory is not null) return _directory;
+ var root = Path.GetFullPath(_temporaryRoot ?? Path.GetTempPath());
+ // Claude Code's quoted @-reference cannot contain a double quote.
+ if (root.Contains('"'))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.Image,
+ $"Images cannot be attached from a temporary directory containing a double quote: {root}; choose another TMPDIR/TEMP");
+ var directory = Path.Combine(root, $"volcanic-claude-images-{Guid.NewGuid():N}");
+ System.IO.Directory.CreateDirectory(directory);
+ if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ _directory = directory;
+ return directory;
+ }
+
+ public void Dispose()
+ {
+ string? directory;
+ lock (_lock)
+ {
+ if (_disposed) return;
+ _disposed = true;
+ directory = _directory;
+ _directory = null;
+ }
+ if (directory is null) return;
+ try
+ {
+ System.IO.Directory.Delete(directory, recursive: true);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Best effort: a stale image directory is harmless.
+ }
+ }
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeInstallation.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeInstallation.cs
new file mode 100644
index 0000000..66a22df
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeInstallation.cs
@@ -0,0 +1,230 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+/// What the doctor and the preflight learned about the installed claude.
+public sealed record ClaudeInstallationInfo(
+ string Path,
+ string? Version,
+ bool LoggedIn,
+ string? AuthMethod,
+ string? SubscriptionType,
+ IReadOnlyList MissingFlags,
+ IReadOnlyList Warnings)
+{
+ /// firstParty for a claude.ai login.
+ public string? ApiProvider { get; init; }
+}
+
+/// Result of one diagnostic run of the CLI.
+public sealed record ProbeResult(int ExitCode, string Stdout, string Stderr, bool TimedOut);
+
+/// Locates and inspects the Claude Code CLI without ever sending it a prompt.
+public static partial class ClaudeInstallation
+{
+ public const string PathVariable = "VOLCANIC_CLAUDE_CODE_PATH";
+ public static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(10);
+
+ ///
+ /// Flags the headless launch relies on, checked against claude --help. --system-prompt-file is
+ /// deliberately represented by --system-prompt: the file variant is documented but hidden from the help screen.
+ ///
+ public static readonly IReadOnlyList RequiredFlags =
+ [
+ "--print", "--setting-sources", "--settings", "--disable-slash-commands", "--permission-mode", "--no-chrome",
+ "--prompt-suggestions", "--output-format", "--input-format", "--include-partial-messages", "--verbose",
+ "--no-session-persistence", "--strict-mcp-config", "--mcp-config", "--tools", "--model", "--effort", "--system-prompt",
+ ];
+
+ /// VOLCANIC_CLAUDE_CODE_PATH, else claude on PATH (with PATHEXT suffixes on Windows); null when absent.
+ public static string? Locate(Func getEnv)
+ {
+ ArgumentNullException.ThrowIfNull(getEnv);
+ var configured = getEnv(PathVariable)?.Trim();
+ if (!string.IsNullOrEmpty(configured)) return configured;
+
+ var names = OperatingSystem.IsWindows() ? WindowsNames(getEnv("PATHEXT")) : ["claude"];
+ foreach (var directory in (getEnv("PATH") ?? "").Split(System.IO.Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
+ {
+ foreach (var name in names)
+ {
+ string candidate;
+ try
+ {
+ candidate = System.IO.Path.Combine(directory, name);
+ }
+ catch (ArgumentException)
+ {
+ continue;
+ }
+ if (File.Exists(candidate) && IsExecutable(candidate)) return candidate;
+ }
+ }
+ return null;
+ }
+
+ /// Required flags that do not appear in the help text.
+ public static IReadOnlyList MissingFlags(string helpText)
+ {
+ ArgumentNullException.ThrowIfNull(helpText);
+ return RequiredFlags.Where(flag => !HasCliOption(helpText, flag)).ToList();
+ }
+
+ /// Runs --version, auth status and --help in parallel (10 s each) with the allow-listed environment.
+ public static async Task InspectAsync(string path, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ if (!File.Exists(path))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ExecutableMissing, $"Claude Code executable was not found: {path}");
+
+ var warnings = new List();
+ var parent = ClaudeArgs.CurrentEnvironment();
+ if (ClaudeArgs.FindDeniedVariable(parent) is { } denied)
+ warnings.Add($"{denied} is set in the environment; the provider never forwards it and refuses requests while it is set");
+ var env = ClaudeArgs.AllowListed(parent, maxOutputTokens: null);
+
+ var versionTask = RunAsync(path, ["--version"], env, ProbeTimeout, cancellationToken);
+ var authTask = RunAsync(path, ["auth", "status"], env, ProbeTimeout, cancellationToken);
+ var helpTask = RunAsync(path, ["--help"], env, ProbeTimeout, cancellationToken);
+ await Task.WhenAll(versionTask, authTask, helpTask).ConfigureAwait(false);
+ var version = await versionTask.ConfigureAwait(false);
+ var auth = await authTask.ConfigureAwait(false);
+ var help = await helpTask.ConfigureAwait(false);
+
+ var versionText = VersionPattern().Match(version.Stdout + "\n" + version.Stderr) is { Success: true } match ? match.Value : null;
+ if (versionText is null) warnings.Add($"Could not determine the Claude Code version{Describe(version)}");
+
+ var loggedIn = false;
+ string? authMethod = null, apiProvider = null, subscription = null;
+ try
+ {
+ using var document = JsonDocument.Parse(ExtractJson(auth.Stdout));
+ var root = document.RootElement;
+ loggedIn = root.TryGetProperty("loggedIn", out var l) && l.ValueKind == JsonValueKind.True;
+ authMethod = StringOrNull(root, "authMethod");
+ apiProvider = StringOrNull(root, "apiProvider");
+ subscription = StringOrNull(root, "subscriptionType")?.ToLowerInvariant();
+ }
+ catch (JsonException)
+ {
+ warnings.Add($"claude auth status returned no JSON; treating the CLI as not logged in{Describe(auth)}");
+ }
+ if (loggedIn && authMethod is not null && authMethod != "claude.ai")
+ warnings.Add($"Claude Code is logged in through '{authMethod}', not a claude.ai subscription; the provider requires subscription auth");
+
+ var missing = MissingFlags(help.Stdout + "\n" + help.Stderr);
+ if (help.ExitCode != 0 && string.IsNullOrWhiteSpace(help.Stdout))
+ warnings.Add($"claude --help failed{Describe(help)}");
+
+ return new ClaudeInstallationInfo(path, versionText, loggedIn, authMethod, subscription, missing, warnings) { ApiProvider = apiProvider };
+ }
+
+ /// Runs the CLI with closed stdin and captured output. Throws executable_missing when it cannot start.
+ public static async Task RunAsync(string executable, IReadOnlyList arguments, IReadOnlyDictionary environment, TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ var psi = new ProcessStartInfo(executable)
+ {
+ UseShellExecute = false,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+ foreach (var argument in arguments) psi.ArgumentList.Add(argument);
+ psi.Environment.Clear();
+ foreach (var (name, value) in environment) 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)
+ {
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ExecutableMissing, $"Claude Code could not be started ({executable}): {ex.Message}");
+ }
+ process.StandardInput.Close();
+ var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
+ var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(timeout);
+ var timedOut = false;
+ try
+ {
+ await process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ timedOut = true;
+ try { process.Kill(entireProcessTree: true); } catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException or AggregateException) { }
+ process.WaitForExit(2000);
+ }
+ if (cancellationToken.IsCancellationRequested)
+ {
+ try { process.Kill(entireProcessTree: true); } catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException or AggregateException) { }
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ var stdout = await stdoutTask.ConfigureAwait(false);
+ var stderr = await stderrTask.ConfigureAwait(false);
+ return new ProbeResult(timedOut ? -1 : process.ExitCode, stdout, stderr, timedOut);
+ }
+
+ private static bool HasCliOption(string helpText, string option)
+ {
+ var pattern = $@"(?:^|[\s,]){Regex.Escape(option)}(?=$|[\s,=<\[])";
+ return Regex.IsMatch(helpText, pattern, RegexOptions.Multiline | RegexOptions.CultureInvariant);
+ }
+
+ private static IReadOnlyList WindowsNames(string? pathext)
+ {
+ var allowed = new HashSet(StringComparer.OrdinalIgnoreCase) { ".exe", ".cmd", ".com" };
+ var suffixes = (pathext ?? ".COM;.EXE;.BAT;.CMD")
+ .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Where(allowed.Contains)
+ .Select(s => s.ToLowerInvariant())
+ .Distinct()
+ .ToList();
+ if (suffixes.Count == 0) suffixes = [".exe", ".cmd", ".com"];
+ return suffixes.Select(s => $"claude{s}").ToList();
+ }
+
+ private static bool IsExecutable(string path)
+ {
+ if (OperatingSystem.IsWindows()) return true;
+ try
+ {
+ var mode = File.GetUnixFileMode(path);
+ return (mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
+ {
+ return false;
+ }
+ }
+
+ private static string ExtractJson(string stdout)
+ {
+ var trimmed = stdout.Trim();
+ var start = trimmed.IndexOf('{');
+ var end = trimmed.LastIndexOf('}');
+ return start >= 0 && end > start ? trimmed[start..(end + 1)] : trimmed;
+ }
+
+ private static string? StringOrNull(JsonElement root, string name) =>
+ root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
+
+ private static string Describe(ProbeResult result)
+ {
+ if (result.TimedOut) return " (timed out)";
+ var stderr = result.Stderr.Trim();
+ var detail = stderr.Length > 200 ? stderr[^200..] : stderr;
+ return detail.Length == 0 ? $" (exit code {result.ExitCode})" : $" (exit code {result.ExitCode}: {detail})";
+ }
+
+ [GeneratedRegex(@"\d+\.\d+\.\d+")]
+ private static partial Regex VersionPattern();
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeProcess.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeProcess.cs
new file mode 100644
index 0000000..40c91bc
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeProcess.cs
@@ -0,0 +1,335 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Globalization;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+/// How to start one Claude Code child.
+public sealed record ClaudeProcessOptions(
+ string Executable,
+ IReadOnlyList Arguments,
+ string WorkingDirectory,
+ IReadOnlyDictionary Environment,
+ TimeSpan IdleTimeout,
+ TimeSpan TotalTimeout);
+
+///
+/// A supervised claude child: replaced (allow-listed) environment, redirected stdio, a rolling stderr tail, an
+/// idle timeout reset on every stdout record, a total timeout, and graceful termination (SIGTERM, then the whole tree).
+///
+public sealed partial class ClaudeProcess : IDisposable
+{
+ public const int MaxStderrChars = 64 * 1024;
+ public const string IdleTimeoutVariable = "VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS";
+ public const string TotalTimeoutVariable = "VOLCANIC_CLAUDE_CODE_TOTAL_TIMEOUT_MS";
+ public static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromMinutes(5);
+ public static readonly TimeSpan DefaultTotalTimeout = TimeSpan.FromMinutes(30);
+ private static readonly TimeSpan GracePeriod = TimeSpan.FromMilliseconds(500);
+ private static readonly TimeSpan KillWait = TimeSpan.FromSeconds(5);
+ private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
+ private const int SigTerm = 15;
+
+ private readonly Process _process;
+ private readonly ClaudeProcessOptions _options;
+ private readonly StringBuilder _stderr = new();
+ private readonly Task _stderrPump;
+ private readonly CancellationTokenSource _idle = new();
+ private readonly CancellationTokenSource _total = new();
+ private readonly Lock _lock = new();
+ private Task? _termination;
+ private bool _disposed;
+
+ private ClaudeProcess(Process process, ClaudeProcessOptions options)
+ {
+ _process = process;
+ _options = options;
+ _stderrPump = Task.Run(PumpStderrAsync);
+ _idle.Token.Register(() => OnTimeout($"Claude Code produced no protocol activity for {options.IdleTimeout.TotalMilliseconds:0}ms"));
+ _total.Token.Register(() => OnTimeout($"Claude Code request exceeded {options.TotalTimeout.TotalMilliseconds:0}ms"));
+ _idle.CancelAfter(options.IdleTimeout);
+ _total.CancelAfter(options.TotalTimeout);
+ }
+
+ /// Starts the child. Throws executable_missing when it cannot be launched.
+ public static ClaudeProcess Start(ClaudeProcessOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ var psi = new ProcessStartInfo(options.Executable)
+ {
+ UseShellExecute = false,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ WorkingDirectory = options.WorkingDirectory,
+ StandardInputEncoding = Utf8NoBom,
+ StandardErrorEncoding = Utf8NoBom,
+ };
+ foreach (var argument in options.Arguments) psi.ArgumentList.Add(argument);
+ psi.Environment.Clear();
+ foreach (var (name, value) in options.Environment) psi.Environment[name] = value;
+
+ var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
+ try
+ {
+ process.Start();
+ }
+ catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException)
+ {
+ process.Dispose();
+ var missing = !File.Exists(options.Executable);
+ throw new ClaudeCodeException(
+ missing ? ClaudeCodeException.Codes.ExecutableMissing : ClaudeCodeException.Codes.ProcessExit,
+ missing ? $"Claude Code executable was not found: {options.Executable}" : $"Claude Code could not be started ({options.Executable}): {ex.Message}");
+ }
+ return new ClaudeProcess(process, options);
+ }
+
+ public int Pid => _process.Id;
+
+ /// Raw stdout bytes (JSONL).
+ public Stream Stdout => _process.StandardOutput.BaseStream;
+
+ public bool HasExited
+ {
+ get
+ {
+ try
+ {
+ return _process.HasExited;
+ }
+ catch (InvalidOperationException)
+ {
+ return true;
+ }
+ }
+ }
+
+ /// Exit code; -1 while running.
+ public int ExitCode
+ {
+ get
+ {
+ try
+ {
+ return _process.HasExited ? _process.ExitCode : -1;
+ }
+ catch (InvalidOperationException)
+ {
+ return -1;
+ }
+ }
+ }
+
+ /// True once was requested (by the provider, a timeout or cancellation).
+ public bool TerminationRequested { get; private set; }
+
+ /// Set when the idle or total timeout terminated the child.
+ public ClaudeCodeException? TimeoutError { get; private set; }
+
+ /// Resets the idle timeout; call on every stdout record.
+ public void Touch()
+ {
+ lock (_lock)
+ {
+ if (_disposed || _idle.IsCancellationRequested) return;
+ _idle.CancelAfter(_options.IdleTimeout);
+ }
+ }
+
+ /// The tail of stderr fit for an error message: one line, bounded, private paths redacted.
+ public string StderrExcerpt(int maxChars = 1000, IEnumerable? privatePaths = null)
+ {
+ string raw;
+ lock (_stderr) raw = _stderr.ToString();
+ var safe = ControlCharacters().Replace(raw, " ").Trim();
+ foreach (var path in (privatePaths ?? []).Where(p => !string.IsNullOrEmpty(p)).OrderByDescending(p => p.Length))
+ safe = safe.Replace(path, "", StringComparison.Ordinal);
+ return safe.Length <= maxChars ? safe : safe[^maxChars..].Trim();
+ }
+
+ /// Writes one line to stdin and closes it. A child that already exited is reported by the exit path, not here.
+ public async Task WriteStdinAsync(string line, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var writer = _process.StandardInput;
+ await writer.WriteAsync(line.AsMemory(), cancellationToken).ConfigureAwait(false);
+ await writer.WriteAsync("\n".AsMemory(), cancellationToken).ConfigureAwait(false);
+ await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
+ writer.Close();
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
+ {
+ // Broken pipe: the child exited before reading; its exit code and stderr explain it.
+ }
+ }
+
+ /// Waits for the child to exit and for its stderr to drain (bounded), then returns the exit code.
+ public async Task WaitForExitAsync(CancellationToken cancellationToken)
+ {
+ await _process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await _stderrPump.WaitAsync(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ // A descendant still holds stderr; the tail collected so far is enough.
+ }
+ return _process.ExitCode;
+ }
+
+ /// Memoised termination: SIGTERM, then after a grace period the whole process tree. Never throws.
+ public Task TerminateAsync()
+ {
+ lock (_lock)
+ {
+ TerminationRequested = true;
+ return _termination ??= TerminateCoreAsync();
+ }
+ }
+
+ private async Task TerminateCoreAsync()
+ {
+ try
+ {
+ if (HasExited) return;
+ if (!OperatingSystem.IsWindows() && TrySendSigterm(Pid))
+ {
+ if (await WaitForExitAsync(GracePeriod).ConfigureAwait(false)) return;
+ }
+ KillTree();
+ await WaitForExitAsync(KillWait).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or ObjectDisposedException)
+ {
+ // The child is gone or the handle was released; either way there is nothing left to terminate.
+ }
+ }
+
+ private async Task WaitForExitAsync(TimeSpan timeout)
+ {
+ if (HasExited) return true;
+ using var cts = new CancellationTokenSource(timeout);
+ try
+ {
+ await _process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
+ return true;
+ }
+ catch (OperationCanceledException)
+ {
+ return HasExited;
+ }
+ }
+
+ private void KillTree()
+ {
+ try
+ {
+ if (!HasExited) _process.Kill(entireProcessTree: true);
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException or AggregateException)
+ {
+ // Already exited, or a descendant could not be signalled; the wait that follows reports what happened.
+ }
+ }
+
+ private void OnTimeout(string message)
+ {
+ lock (_lock)
+ {
+ if (_disposed || TerminationRequested || HasExited) return;
+ TimeoutError ??= new ClaudeCodeException(ClaudeCodeException.Codes.Timeout, message);
+ }
+ _ = TerminateAsync();
+ }
+
+ private async Task PumpStderrAsync()
+ {
+ try
+ {
+ var reader = _process.StandardError;
+ var buffer = new char[4096];
+ int read;
+ while ((read = await reader.ReadAsync(buffer).ConfigureAwait(false)) > 0)
+ {
+ lock (_stderr)
+ {
+ _stderr.Append(buffer, 0, read);
+ if (_stderr.Length > MaxStderrChars) _stderr.Remove(0, _stderr.Length - MaxStderrChars);
+ }
+ }
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
+ {
+ // stderr closed underneath us; the tail collected so far stands.
+ }
+ }
+
+ [DllImport("libc", EntryPoint = "kill", SetLastError = true)]
+ private static extern int SysKill(int pid, int signal);
+
+ /// SIGTERM through libc, else through the kill command. Returns false when neither could signal the child.
+ private static bool TrySendSigterm(int pid)
+ {
+ try
+ {
+ return SysKill(pid, SigTerm) == 0;
+ }
+ catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or BadImageFormatException)
+ {
+ // Fall through to the command.
+ }
+ try
+ {
+ var psi = new ProcessStartInfo("kill")
+ {
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+ psi.ArgumentList.Add("-TERM");
+ psi.ArgumentList.Add(pid.ToString(CultureInfo.InvariantCulture));
+ using var kill = Process.Start(psi);
+ return kill is not null && kill.WaitForExit(2000) && kill.ExitCode == 0;
+ }
+ catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException)
+ {
+ return false;
+ }
+ }
+
+ /// A positive millisecond count from the environment, or the fallback when unset. Invalid values fail loudly.
+ public static TimeSpan TimeoutSetting(Func getEnv, string name, TimeSpan fallback)
+ {
+ ArgumentNullException.ThrowIfNull(getEnv);
+ var raw = getEnv(name)?.Trim();
+ if (string.IsNullOrEmpty(raw)) return fallback;
+ if (!int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out var milliseconds) || milliseconds <= 0)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.Timeout, $"{name} must be a positive integer number of milliseconds");
+ return TimeSpan.FromMilliseconds(milliseconds);
+ }
+
+ public void Dispose()
+ {
+ lock (_lock)
+ {
+ if (_disposed) return;
+ _disposed = true;
+ }
+ try { _idle.Cancel(); } catch (ObjectDisposedException) { }
+ try { _total.Cancel(); } catch (ObjectDisposedException) { }
+ KillTree();
+ _idle.Dispose();
+ _total.Dispose();
+ _process.Dispose();
+ }
+
+ [GeneratedRegex(@"[\u0000-\u001f\u007f]+")]
+ private static partial Regex ControlCharacters();
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ContextSerializer.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ContextSerializer.cs
new file mode 100644
index 0000000..fc43b10
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ContextSerializer.cs
@@ -0,0 +1,231 @@
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Volcanic.AgenticCore.Abstractions.Messages;
+using Volcanic.AgenticCore.Abstractions.Providers;
+
+namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+/// The transcript as NDJSON records (already @-escaped) plus the image files it references.
+public sealed record SerializedTranscript(
+ IReadOnlyList Records,
+ IReadOnlyList AttachmentPaths,
+ int ImageCount,
+ long ImageBytes,
+ long TranscriptBytes);
+
+///
+/// Serialises the conversation into append-stable JSON records, one per message after a header record, each carried
+/// as its own text block of a single stream-json user message. Historical tool identities are tied to the current
+/// catalogue; UI-only details stay out of the model transcript.
+///
+public static class ContextSerializer
+{
+ public const string Protocol = "volcanic-claude-code-context-v1";
+ public const string Instruction =
+ "Continue this conversation. Treat each following JSON record as conversation data, preserve role boundaries, " +
+ "and answer only the current request. Use available MCP tools when a tool is needed. " +
+ "Bracketed unavailable tool labels are historical data, not callable tools.";
+ public const int MaxTranscriptBytes = 32 * 1024 * 1024;
+ public const string AttachmentInstructionPrefix = "Generated image attachments for image_attachment blocks: ";
+
+ private static readonly JsonSerializerOptions RecordOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
+
+ ///
+ /// The one stream-json user message written to Claude Code's stdin. The last transcript block carries a 1h
+ /// cache breakpoint when is set; attachment references follow it, outside the breakpoint.
+ ///
+ public static JsonObject BuildStdinMessage(LlmContext context, ToolCatalog catalog, ClaudeImageStore images, bool cacheBreakpoint)
+ {
+ var transcript = Serialize(context, catalog, images);
+ var content = new JsonArray();
+ for (var i = 0; i < transcript.Records.Count; i++)
+ {
+ var block = new JsonObject { ["type"] = "text", ["text"] = transcript.Records[i] };
+ if (cacheBreakpoint && i == transcript.Records.Count - 1)
+ block["cache_control"] = new JsonObject { ["type"] = "ephemeral", ["ttl"] = "1h" };
+ content.Add(block);
+ }
+ if (transcript.AttachmentPaths.Count > 0)
+ {
+ // Quoted absolute references: Claude runs in the session directory, where a relative reference would
+ // resolve against the project, and the quotes keep a temporary root containing spaces in one reference.
+ var references = string.Join(" ", transcript.AttachmentPaths.Select(path => $"@\"{path}\""));
+ content.Add(new JsonObject { ["type"] = "text", ["text"] = $"{AttachmentInstructionPrefix}{references}." });
+ }
+ return new JsonObject
+ {
+ ["type"] = "user",
+ ["message"] = new JsonObject { ["role"] = "user", ["content"] = content },
+ };
+ }
+
+ /// Serialises the header and one record per message. Images are written to .
+ public static SerializedTranscript Serialize(LlmContext context, ToolCatalog catalog, ClaudeImageStore images)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(catalog);
+ ArgumentNullException.ThrowIfNull(images);
+
+ var attachments = new List();
+ var written = new HashSet(StringComparer.Ordinal);
+ var imageCount = 0;
+ long imageBytes = 0;
+ var historicalNamesByCallId = new Dictionary(StringComparer.Ordinal);
+
+ string HistoricalName(string toolName) =>
+ catalog.ToolToTransport.TryGetValue(toolName, out var transport) ? transport : UnavailableToolLabel(toolName);
+
+ JsonObject Attach(ImageContent image)
+ {
+ imageCount++;
+ if (imageCount > ClaudeImageStore.MaxImagesPerRequest)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, $"At most {ClaudeImageStore.MaxImagesPerRequest} images are supported per request");
+ var attachment = images.Add(image);
+ if (written.Add(attachment.FileName))
+ {
+ imageBytes += attachment.Bytes;
+ if (imageBytes > ClaudeImageStore.MaxTotalImageBytes)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, $"Aggregate image size exceeds {ClaudeImageStore.MaxTotalImageBytes} bytes");
+ if (attachment.AbsolutePath.Contains('"'))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, $"Images cannot be attached from a path containing a double quote: {attachment.AbsolutePath}");
+ attachments.Add(attachment.AbsolutePath);
+ }
+ return new JsonObject { ["type"] = "image_attachment", ["attachment"] = attachment.FileName, ["mimeType"] = image.MimeType };
+ }
+
+ JsonArray UserContent(IReadOnlyList blocks, string role)
+ {
+ var array = new JsonArray();
+ foreach (var block in blocks)
+ {
+ array.Add(block switch
+ {
+ TextContent text => new JsonObject { ["type"] = "text", ["text"] = text.Text },
+ ImageContent image => Attach(image),
+ _ => throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"{BlockName(block)} content must not appear in {role} messages"),
+ });
+ }
+ return array;
+ }
+
+ var records = new List
+ {
+ new()
+ {
+ ["protocol"] = Protocol,
+ ["instruction"] = Instruction,
+ ["toolNameMap"] = new JsonArray(catalog.Tools
+ .Select(tool => (JsonNode)new JsonObject { ["transportName"] = catalog.ToolToTransport[tool.Name], ["toolName"] = tool.Name })
+ .ToArray()),
+ },
+ };
+
+ foreach (var message in context.Messages)
+ {
+ switch (message)
+ {
+ case UserMessage user:
+ records.Add(new JsonObject { ["role"] = "user", ["content"] = UserRecordContent(user.Content, UserContent) });
+ break;
+ case CustomMessage custom:
+ // Custom messages reach the model as user turns, exactly as the core's transcript normaliser renders them.
+ records.Add(new JsonObject { ["role"] = "user", ["content"] = UserRecordContent(custom.Content, UserContent) });
+ break;
+ case AssistantMessage assistant:
+ {
+ var content = new JsonArray();
+ foreach (var block in assistant.Content)
+ {
+ switch (block)
+ {
+ case TextContent text:
+ content.Add(new JsonObject { ["type"] = "text", ["text"] = text.Text });
+ break;
+ case ThinkingContent thinking:
+ {
+ var node = new JsonObject { ["type"] = "thinking", ["thinking"] = thinking.Thinking };
+ if (thinking.Redacted) node["redacted"] = true;
+ content.Add(node);
+ break;
+ }
+ case ToolCallContent call:
+ {
+ if (string.IsNullOrWhiteSpace(call.Id))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Tool-call ID must be a nonempty string");
+ if (string.IsNullOrWhiteSpace(call.Name))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Tool-call name must be a nonempty string");
+ var name = HistoricalName(call.Name);
+ historicalNamesByCallId[call.Id] = name;
+ var arguments = call.Arguments.ValueKind == JsonValueKind.Object
+ ? JsonNode.Parse(call.Arguments.GetRawText()) ?? new JsonObject()
+ : new JsonObject();
+ content.Add(new JsonObject { ["type"] = "toolCall", ["id"] = call.Id, ["name"] = name, ["arguments"] = arguments });
+ break;
+ }
+ default:
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"{BlockName(block)} content must not appear in assistant messages");
+ }
+ }
+ if (content.Count > 0) records.Add(new JsonObject { ["role"] = "assistant", ["content"] = content });
+ break;
+ }
+ case ToolResultMessage result:
+ {
+ if (string.IsNullOrWhiteSpace(result.ToolCallId))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Tool-result ID must be a nonempty string");
+ if (string.IsNullOrWhiteSpace(result.ToolName))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Tool-result name must be a nonempty string");
+ var toolName = historicalNamesByCallId.TryGetValue(result.ToolCallId, out var paired) ? paired : HistoricalName(result.ToolName);
+ records.Add(new JsonObject
+ {
+ ["role"] = "toolResult",
+ ["toolCallId"] = result.ToolCallId,
+ ["toolName"] = toolName,
+ ["content"] = UserContent(result.Content, "toolResult"),
+ ["isError"] = result.IsError,
+ });
+ break;
+ }
+ default:
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"Unsupported message role: {message.Role}");
+ }
+ }
+
+ var lines = new List(records.Count);
+ long transcriptBytes = 0;
+ foreach (var record in records)
+ {
+ var line = EscapeAtSigns(record.ToJsonString(RecordOptions));
+ transcriptBytes += Encoding.UTF8.GetByteCount(line) + (lines.Count > 0 ? 1 : 0);
+ lines.Add(line);
+ }
+ if (transcriptBytes > MaxTranscriptBytes)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ContextOverflow,
+ $"context_length_exceeded: Serialized context exceeds {MaxTranscriptBytes} bytes");
+
+ return new SerializedTranscript(lines, attachments, imageCount, imageBytes, transcriptBytes);
+ }
+
+ ///
+ /// Claude Code expands @path references anywhere in a prompt, so every @ in a record is written as
+ /// the JSON escape @: the record still parses to the same text, and nothing is expanded.
+ ///
+ public static string EscapeAtSigns(string json) => json.Replace("@", "\\u0040", StringComparison.Ordinal);
+
+ /// Label for a tool that is no longer in the catalogue; bracketed so the model reads it as history.
+ public static string UnavailableToolLabel(string toolName) => $"[unavailable tool {toolName}]";
+
+ private static JsonNode UserRecordContent(IReadOnlyList blocks, Func, string, JsonArray> serialize) =>
+ blocks.Count == 1 && blocks[0] is TextContent single ? JsonValue.Create(single.Text) : serialize(blocks, "user");
+
+ private static string BlockName(ContentBlock block) => block switch
+ {
+ TextContent => "text",
+ ImageContent => "image",
+ ThinkingContent => "thinking",
+ ToolCallContent => "toolCall",
+ _ => block.GetType().Name,
+ };
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/DoctorCommand.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/DoctorCommand.cs
new file mode 100644
index 0000000..25acc5e
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/DoctorCommand.cs
@@ -0,0 +1,190 @@
+using System.Text.Json;
+using Volcanic.AgenticCore.Abstractions.Extensions;
+using Volcanic.AgenticCore.Abstractions.Messages;
+using Volcanic.AgenticCore.Abstractions.Ui;
+
+namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+/// Outcome of a real initialize + tools/list handshake against the proposal bridge.
+public sealed record BridgeProbeResult(bool Ok, IReadOnlyList Argv, string Detail);
+
+/// /claude-code-doctor: reports the CLI path, version, login, required flags and a bridge handshake probe.
+public static class DoctorCommand
+{
+ public const string Name = "claude-code-doctor";
+ public const string Description = "Check the Claude Code CLI, its login, the required flags and the proposal MCP bridge";
+ public static readonly TimeSpan BridgeProbeTimeout = TimeSpan.FromSeconds(10);
+
+ public static CommandDefinition Create(IExtensionApi api, ClaudeCodeApiOptions options, SessionRegistry registry)
+ {
+ ArgumentNullException.ThrowIfNull(api);
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(registry);
+ return new CommandDefinition(Description, async (_, ctx, ct) =>
+ {
+ string report;
+ var ok = false;
+ try
+ {
+ (report, ok) = await BuildReportAsync(options, registry, ct).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ report = $"# Claude Code doctor\n\nThe doctor itself failed: {ex.Message}";
+ }
+ // Idle and triggerTurn false: the report is appended to the transcript for the user without starting a turn.
+ api.SendMessage(CustomMessage.FromText(Name, report), MessageDelivery.Steer, triggerTurn: false);
+ if (ctx.HasUi)
+ {
+ ctx.Ui.Notify(
+ ok ? "Claude Code doctor: everything checks out" : $"Claude Code doctor: problems found; see the {Name} message",
+ ok ? NotifyKind.Info : NotifyKind.Warning);
+ }
+ });
+ }
+
+ /// Builds the markdown report. Ok is false when anything would stop a request from working.
+ public static async Task<(string Report, bool Ok)> BuildReportAsync(ClaudeCodeApiOptions options, SessionRegistry registry, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(registry);
+ var lines = new List { "# Claude Code doctor", "" };
+ var ok = true;
+
+ var claudePath = options.ClaudePath;
+ ClaudeInstallationInfo? info = null;
+ string? inspectError = null;
+ if (string.IsNullOrEmpty(claudePath))
+ {
+ ok = false;
+ lines.Add($"- Claude Code: **not found**. Install the `claude` CLI or set `{ClaudeInstallation.PathVariable}` to its executable.");
+ }
+ else
+ {
+ try
+ {
+ info = await ClaudeInstallation.InspectAsync(claudePath, cancellationToken).ConfigureAwait(false);
+ }
+ catch (ClaudeCodeException ex)
+ {
+ inspectError = ex.Message;
+ }
+ }
+ if (info is not null)
+ {
+ lines.Add($"- Claude Code: `{info.Path}` version {info.Version ?? "unknown"}");
+ if (info.LoggedIn)
+ {
+ var subscription = info.SubscriptionType is null ? "" : $" ({info.SubscriptionType} subscription)";
+ lines.Add($"- Auth: logged in via {info.AuthMethod ?? "unknown"}{subscription}");
+ }
+ else
+ {
+ ok = false;
+ lines.Add("- Auth: **not logged in**; run `claude auth login` with a Claude subscription");
+ }
+ if (info.MissingFlags.Count == 0) lines.Add("- Required flags: all present");
+ else
+ {
+ ok = false;
+ lines.Add($"- Required flags: **missing** {string.Join(", ", info.MissingFlags)}; update Claude Code");
+ }
+ foreach (var warning in info.Warnings) lines.Add($"- Warning: {warning}");
+ }
+ else if (inspectError is not null)
+ {
+ ok = false;
+ lines.Add($"- Claude Code: `{claudePath}` could not be inspected: {inspectError}");
+ }
+
+ var probe = await ProbeBridgeAsync(options.DotnetPath, options.BridgeDllPath, BridgeProbeTimeout, cancellationToken).ConfigureAwait(false);
+ if (!probe.Ok) ok = false;
+ lines.Add($"- Bridge: {(probe.Ok ? "ok" : "**BROKEN**")} via `{JsonSerializer.Serialize(probe.Argv)}` ({probe.Detail})");
+
+ if (ClaudeArgs.FindDeniedVariable(options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment()) is { } denied)
+ {
+ ok = false;
+ lines.Add($"- Environment: **{denied} is set**; the provider refuses to run while API-key or third-party credentials are present");
+ }
+
+ lines.Add($"- Models: {string.Join(", ", ClaudeCodeModels.All.Select(m => m.Id))}");
+ lines.Add($"- Registered sessions: {registry.Count}");
+ lines.Add("");
+ lines.Add(ok ? "Everything checks out." : $"Problems found; fix the items in bold and run `/{Name}` again.");
+ return (string.Join("\n", lines), ok);
+ }
+
+ ///
+ /// Completes a real initialize plus tools/list handshake against the bridge with a one-tool catalogue, mirroring
+ /// what Claude Code hands it (the allow-listed environment plus the catalogue and ready-marker variables).
+ ///
+ public static async Task ProbeBridgeAsync(string? dotnetPath, string? bridgeDllPath, TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ var argv = new List { dotnetPath ?? "dotnet", bridgeDllPath ?? BridgeLocator.BridgeFileName };
+ if (string.IsNullOrEmpty(dotnetPath)) return new BridgeProbeResult(false, argv, "the dotnet muxer was not located");
+ if (string.IsNullOrEmpty(bridgeDllPath) || !File.Exists(bridgeDllPath))
+ return new BridgeProbeResult(false, argv, $"bridge assembly not found{(bridgeDllPath is null ? "" : $": {bridgeDllPath}")}");
+
+ var directory = Directory.CreateTempSubdirectory("volcanic-claude-doctor-").FullName;
+ try
+ {
+ var catalogPath = Path.Combine(directory, "catalog.json");
+ var readyPath = Path.Combine(directory, "ready");
+ await File.WriteAllTextAsync(catalogPath, "[{\"name\":\"probe\",\"description\":\"doctor probe\",\"inputSchema\":{\"type\":\"object\"}}]", cancellationToken).ConfigureAwait(false);
+ var env = new Dictionary(ClaudeArgs.AllowListed(ClaudeArgs.CurrentEnvironment(), maxOutputTokens: null), StringComparer.Ordinal)
+ {
+ [ClaudeArgs.CatalogVariable] = catalogPath,
+ [ClaudeArgs.ReadyVariable] = readyPath,
+ };
+ using var process = ClaudeProcess.Start(new ClaudeProcessOptions(dotnetPath, [bridgeDllPath], directory, env, timeout, timeout));
+ await process.WriteStdinAsync(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}",
+ cancellationToken).ConfigureAwait(false);
+
+ var responses = new List();
+ try
+ {
+ await foreach (var record in JsonlReader.ReadAsync(process.Stdout, cancellationToken: cancellationToken).ConfigureAwait(false))
+ {
+ process.Touch();
+ responses.Add(record);
+ }
+ }
+ catch (ClaudeCodeException ex)
+ {
+ return new BridgeProbeResult(false, argv, $"bridge output was not JSONL: {ex.Message}");
+ }
+ var exitCode = await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ if (process.TimeoutError is { } timeoutError) return new BridgeProbeResult(false, argv, timeoutError.Message);
+ if (exitCode != 0) return new BridgeProbeResult(false, argv, $"bridge process failed (exit code {exitCode}){Stderr(process)}");
+
+ var toolCount = -1;
+ foreach (var response in responses)
+ {
+ if (!response.TryGetProperty("id", out var id) || id.ValueKind != JsonValueKind.Number || !id.TryGetInt32(out var value) || value != 2) continue;
+ toolCount = response.TryGetProperty("result", out var result) && result.TryGetProperty("tools", out var tools) && tools.ValueKind == JsonValueKind.Array
+ ? tools.GetArrayLength()
+ : -1;
+ }
+ var ready = File.Exists(readyPath);
+ if (toolCount != 1 || !ready)
+ return new BridgeProbeResult(false, argv,
+ $"handshake failed ({(toolCount < 0 ? "no tools/list result" : $"{toolCount} tools")}, ready marker {(ready ? "written" : "missing")}){Stderr(process)}");
+ return new BridgeProbeResult(true, argv, "handshake completed: 1 tool listed, ready marker written");
+ }
+ catch (ClaudeCodeException ex)
+ {
+ return new BridgeProbeResult(false, argv, ex.Message);
+ }
+ finally
+ {
+ try { Directory.Delete(directory, recursive: true); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
+ }
+ }
+
+ private static string Stderr(ClaudeProcess process)
+ {
+ var excerpt = process.StderrExcerpt(256);
+ return excerpt.Length == 0 ? "" : $": {excerpt}";
+ }
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/JsonlReader.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/JsonlReader.cs
new file mode 100644
index 0000000..04f7492
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/JsonlReader.cs
@@ -0,0 +1,79 @@
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+
+namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+/// Strict JSONL framing over a byte stream: records split on LF only, one optional CR before it, UTF-8, bounded size.
+public static class JsonlReader
+{
+ public const int DefaultMaxRecordBytes = 8 * 1024 * 1024;
+
+ public static async IAsyncEnumerable ReadAsync(
+ Stream stream,
+ int maxRecordBytes = DefaultMaxRecordBytes,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ var buffer = new byte[64 * 1024];
+ using var pending = new MemoryStream();
+ while (true)
+ {
+ var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
+ if (read == 0) break;
+ var start = 0;
+ for (var i = 0; i < read; i++)
+ {
+ if (buffer[i] != (byte)'\n') continue;
+ pending.Write(buffer, start, i - start);
+ start = i + 1;
+ if (TryParse(pending, maxRecordBytes, out var element)) yield return element;
+ }
+ pending.Write(buffer, start, read - start);
+ if (pending.Length > maxRecordBytes + 1) throw Oversized();
+ }
+ if (pending.Length > 0 && TryParse(pending, maxRecordBytes, out var last)) yield return last;
+ }
+
+ /// Parses one framed line. Returns false for a blank line. The pending buffer is reset either way.
+ private static bool TryParse(MemoryStream pending, int maxRecordBytes, out JsonElement element)
+ {
+ var length = (int)pending.Length;
+ var bytes = pending.GetBuffer().AsMemory(0, length);
+ pending.SetLength(0);
+ if (length > 0 && bytes.Span[length - 1] == (byte)'\r') bytes = bytes[..(length - 1)];
+ if (bytes.Length > maxRecordBytes) throw Oversized();
+ if (IsBlank(bytes.Span))
+ {
+ element = default;
+ return false;
+ }
+ element = Parse(bytes);
+ return true;
+ }
+
+ /// Parses one record (no framing). Throws protocol_stream for malformed JSON.
+ public static JsonElement Parse(ReadOnlyMemory line)
+ {
+ try
+ {
+ using var document = JsonDocument.Parse(line);
+ return document.RootElement.Clone();
+ }
+ catch (JsonException)
+ {
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolStream, "Claude Code emitted malformed JSONL");
+ }
+ }
+
+ private static bool IsBlank(ReadOnlySpan bytes)
+ {
+ foreach (var b in bytes)
+ {
+ if (b is not ((byte)' ' or (byte)'\t' or (byte)'\r')) return false;
+ }
+ return true;
+ }
+
+ private static ClaudeCodeException Oversized() =>
+ new(ClaudeCodeException.Codes.ProtocolStream, "Claude Code emitted an oversized JSONL record");
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/SessionRegistry.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/SessionRegistry.cs
new file mode 100644
index 0000000..c2d0bb3
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/SessionRegistry.cs
@@ -0,0 +1,155 @@
+using System.Collections.Concurrent;
+using System.Text.RegularExpressions;
+using Volcanic.AgenticCore.Abstractions.Providers;
+using Volcanic.AgenticCore.Abstractions.Ui;
+
+namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+/// The per-session state a request needs: where Claude runs, where its images live, and how to notify the user.
+public sealed record ClaudeSessionEntry(string Cwd, ClaudeImageStore Images, Action? Notify);
+
+///
+/// Process-global map of live sessions. A host can hold several sessions and every extension instance registers its
+/// own, so a request must resolve the directory of the session it belongs to rather than the last one registered.
+///
+public sealed partial class SessionRegistry
+{
+ public const string SourceSession = "session";
+ public const string SourcePrompt = "prompt";
+ public const string SourceOneshot = "oneshot";
+
+ private sealed record Registered(ClaudeSessionEntry Entry, long Sequence);
+
+ private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal);
+ private readonly Lock _lock = new();
+ private long _sequence;
+ private ClaudeImageStore? _detached;
+
+ /// The registry shared by every extension instance in the process.
+ public static SessionRegistry Shared { get; } = new();
+
+ public int Count => _sessions.Count;
+
+ /// Registers or updates a session. A re-registration keeps the session's image store.
+ public ClaudeSessionEntry Register(string sessionId, string cwd, Action? notify = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(cwd);
+ var sequence = Interlocked.Increment(ref _sequence);
+ var registered = _sessions.AddOrUpdate(
+ sessionId,
+ _ => new Registered(new ClaudeSessionEntry(cwd, new ClaudeImageStore(), notify), sequence),
+ (_, existing) => new Registered(existing.Entry with { Cwd = cwd, Notify = notify }, sequence));
+ return registered.Entry;
+ }
+
+ /// Removes a session and deletes its image store.
+ public bool Remove(string sessionId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
+ if (!_sessions.TryRemove(sessionId, out var registered)) return false;
+ registered.Entry.Images.Dispose();
+ return true;
+ }
+
+ public ClaudeSessionEntry? Get(string sessionId) =>
+ sessionId is not null && _sessions.TryGetValue(sessionId, out var registered) ? registered.Entry : null;
+
+ ///
+ /// Resolves the working directory for a request: the registered session, else the directory the system prompt
+ /// states, else (for tool-free one-shots such as compaction summaries) the newest live session. Tool-bearing
+ /// requests with no recognised directory are refused rather than run in another session's tree.
+ ///
+ public (string Cwd, ClaudeImageStore Images, string Source) Resolve(string? sessionId, LlmContext context, bool hasTools)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ var stated = PromptWorkingDirectory(context.SystemPrompt);
+
+ if (sessionId is not null && _sessions.TryGetValue(sessionId, out var registered))
+ {
+ var entry = registered.Entry;
+ if (stated is not null && !SameDirectory(stated, entry.Cwd))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.WorkingDirectory,
+ $"The system prompt names {stated} as the working directory but session {sessionId} runs in {entry.Cwd}; refusing to run Claude Code in another session's directory");
+ return (RequireDirectory(entry.Cwd), entry.Images, SourceSession);
+ }
+
+ var live = _sessions.Values.OrderBy(r => r.Sequence).Select(r => r.Entry).ToList();
+ if (stated is not null)
+ {
+ // A session this process never registered (an extension driving its own loop, a host with several sessions):
+ // run where the prompt says and borrow private state from a live session, preferring one in that directory.
+ var host = live.LastOrDefault(e => SameDirectory(e.Cwd, stated)) ?? live.LastOrDefault();
+ return (RequireDirectory(stated), host?.Images ?? DetachedImages(), SourcePrompt);
+ }
+
+ if (!hasTools && live.Count > 0)
+ {
+ // Compaction summaries arrive with no session id, no tools and no directory: host them in the newest session.
+ var newest = live[^1];
+ return (RequireDirectory(newest.Cwd), newest.Images, SourceOneshot);
+ }
+
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.WorkingDirectory, live.Count == 0
+ ? "No session is registered and the system prompt names no working directory; the provider can only run inside a started session"
+ : $"This tool-bearing request ({sessionId ?? "no session id"}) has no registered session or recognised working directory in its system prompt and {live.Count} session(s) are live; refusing to borrow another session's directory");
+ }
+
+ ///
+ /// The working directory a system prompt states: the last <cwd> section or the last
+ /// Working directory: X line, whichever comes later. Sections nested in project context (repository-authored
+ /// instruction files) are discarded so a repository can never choose where Claude runs.
+ ///
+ public static string? PromptWorkingDirectory(string? systemPrompt)
+ {
+ if (string.IsNullOrEmpty(systemPrompt)) return null;
+ var authored = ProjectContextSpan().Matches(systemPrompt).Select(m => (Start: m.Index, End: m.Index + m.Length)).ToList();
+ bool RepositoryAuthored(int index) => authored.Any(span => index >= span.Start && index < span.End);
+
+ Match? best = null;
+ foreach (Match section in CwdSection().Matches(systemPrompt))
+ {
+ if (!RepositoryAuthored(section.Index)) best = section;
+ }
+ foreach (Match line in WorkingDirectoryLine().Matches(systemPrompt))
+ {
+ if (!RepositoryAuthored(line.Index) && (best is null || line.Index > best.Index)) best = line;
+ }
+ var stated = best?.Groups[1].Value.Trim();
+ return string.IsNullOrEmpty(stated) ? null : stated;
+ }
+
+ /// Separator-insensitive comparison; case-insensitive on Windows.
+ public static bool SameDirectory(string left, string right) =>
+ string.Equals(Normalize(left), Normalize(right), OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
+
+ private static string Normalize(string path)
+ {
+ var separated = path.Replace('\\', '/');
+ var trimmed = separated.TrimEnd('/');
+ return trimmed.Length == 0 ? separated : trimmed;
+ }
+
+ private static string RequireDirectory(string cwd)
+ {
+ if (!Path.IsPathRooted(cwd))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.WorkingDirectory, $"The session working directory is not absolute: {cwd}");
+ if (!Directory.Exists(cwd))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.WorkingDirectory, $"The session working directory is unavailable: {cwd}; restart the session in an existing directory");
+ return cwd;
+ }
+
+ private ClaudeImageStore DetachedImages()
+ {
+ lock (_lock) return _detached ??= new ClaudeImageStore();
+ }
+
+ [GeneratedRegex(@"<(project_context|project_instructions)\b[^>]*>[\s\S]*?\1>", RegexOptions.CultureInvariant)]
+ private static partial Regex ProjectContextSpan();
+
+ [GeneratedRegex(@"\r?\n([^\n]+)\r?\n", RegexOptions.CultureInvariant)]
+ private static partial Regex CwdSection();
+
+ [GeneratedRegex(@"^(?:Current )?[Ww]orking directory: ([^\r\n]+?)[ \t]*\r?$", RegexOptions.Multiline | RegexOptions.CultureInvariant)]
+ private static partial Regex WorkingDirectoryLine();
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/StreamEventMapper.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/StreamEventMapper.cs
new file mode 100644
index 0000000..2c30320
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/StreamEventMapper.cs
@@ -0,0 +1,750 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Volcanic.AgenticCore.Abstractions.Messages;
+using Volcanic.AgenticCore.Abstractions.Providers;
+
+namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+///
+/// Maps Claude Code's stream-json stdout records to s through one
+/// . Validates the system/init record, latches a handoff on a
+/// tool_use or length stop (after which every later stream event belongs to a turn the core never asked for
+/// and is ignored), and reports Claude Code's mid-response recoveries as a retryable stream_interrupted failure.
+///
+public sealed partial class StreamEventMapper
+{
+ // api_retry categories that describe a transient transport or server failure. Every other category
+ // (billing_error, authentication_failed, invalid_request, ...) cannot be cleared by repeating the turn.
+ private static readonly HashSet RetryableInterruptions = new(StringComparer.Ordinal) { "overloaded", "server_error", "unknown" };
+ private const string InterruptedPhrase = "Claude Code API stream ended before message_stop";
+ private const long EpochMillisecondsThreshold = 100_000_000_000;
+
+ private enum BlockKind { None, Text, Thinking, ToolCall }
+
+ private readonly ToolCatalog _catalog;
+ private readonly List _notices = [];
+ private readonly StringBuilder _partialJson = new();
+ private bool _messageStarted;
+ private bool _messageStopped;
+ private bool _successfulResult;
+ private bool _finished;
+ private string? _streamMessageId;
+ private string? _stopReason;
+ private string? _deferredToolArguments;
+ private string? _rateLimitFailure;
+ private string? _assistantDiagnostic;
+ private string? _openToolName;
+ private int? _openIndex;
+ private BlockKind _openKind;
+ private JsonElement? _initialInput;
+ private int _input, _output, _cacheRead, _cacheWrite, _reasoning;
+
+ public StreamEventMapper(Model model, ToolCatalog catalog, AssistantMessageBuilder? builder = null)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+ ArgumentNullException.ThrowIfNull(catalog);
+ Model = model;
+ _catalog = catalog;
+ Builder = builder ?? new AssistantMessageBuilder(model);
+ }
+
+ public Model Model { get; }
+
+ /// The builder behind every event; the provider uses it to Fail the stream.
+ public AssistantMessageBuilder Builder { get; }
+
+ public bool SawInit { get; private set; }
+ public bool SawResult { get; private set; }
+
+ /// A tool-use or length stop was reported: the provider terminates Claude and later stream events are ignored.
+ public bool HandoffLatched { get; private set; }
+
+ /// The stop reason a completed response will carry: ToolUse or Length after a latch, Stop after a successful result.
+ public StopReason? PendingStop { get; private set; }
+
+ public string? ResponseModel { get; private set; }
+ public Usage Usage => Builder.Usage;
+ public bool MessageStarted => _messageStarted;
+ public string? RawStopReason => _stopReason;
+ public bool HasSuccessfulResult => _successfulResult;
+ public bool IsComplete => _finished;
+
+ /// A held tool-argument failure for the provider to report when no recovery signal claimed it.
+ public string? DeferredError => _deferredToolArguments;
+
+ /// The most recent rejected rate limit, used as the failure detail when Claude exits without a terminal event.
+ public string? RateLimitFailure => _rateLimitFailure;
+
+ /// Rate-limit notices (warnings and rejections) in arrival order, formatted for a user notification.
+ public IReadOnlyList Notices => _notices;
+
+ public int? ServedContextWindow { get; private set; }
+ public int? ServedMaxOutputTokens { get; private set; }
+
+ /// Maps one stdout record to the events to publish. Throws on protocol failures.
+ public IReadOnlyList Accept(JsonElement record)
+ {
+ if (record.ValueKind != JsonValueKind.Object) throw Stream("Invalid Claude event record");
+ var type = Str(record, "type");
+ var subtype = Str(record, "subtype");
+ if (type == "system" && subtype == "init") return ValidateInit(record);
+ if (!SawInit)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolInit, $"Claude emitted a {RecordKind(type, subtype)} record before initialization");
+ if (SawResult) throw Stream("Claude emitted a record after its result");
+
+ switch (type)
+ {
+ case "stream_event":
+ // Once a handoff is latched the provider is terminating Claude, but records already in the pipe still
+ // arrive. Claude Code answers a tool denial or an output limit with another message of its own, so these
+ // events belong to a turn the core never asked for: neither published nor rejected as protocol drift.
+ if (HandoffLatched) return [];
+ return AcceptStreamEvent(Obj(record, "event") ?? throw Stream("Claude stream_event did not contain an event"));
+ case "result":
+ return AcceptResult(record);
+ case "rate_limit_event":
+ AcceptRateLimit(record);
+ return [];
+ case "assistant":
+ RejectInterruptedStream(record, type, subtype);
+ AcceptAssistant(record);
+ return [];
+ case "user":
+ case "system":
+ // Completed user echoes and non-init system status records are redundant with the partial stream events.
+ RejectInterruptedStream(record, type, subtype);
+ return [];
+ default:
+ throw Stream($"Unsupported Claude record type: {type ?? "untyped"}");
+ }
+ }
+
+ ///
+ /// The terminal Done event, produced once the provider has validated the process exit and private state.
+ /// Reason: ToolUse or Length after a latched handoff, else Stop/Length from the successful result.
+ ///
+ public AssistantMessageEvent Complete()
+ {
+ if (_finished) throw new InvalidOperationException("The response was already completed.");
+ ThrowDeferredFailure();
+ if (_openIndex is not null) throw Stream("Claude response completed with unclosed content blocks");
+ switch (PendingStop)
+ {
+ case StopReason.ToolUse:
+ if (!Builder.Current.Content.OfType().Any()) throw Stream("Claude reported tool use without a tool call");
+ break;
+ case StopReason.Length:
+ if (!IsLengthStop(_stopReason)) throw Stream("Claude did not report an output limit stop");
+ break;
+ default:
+ if (!_successfulResult) throw Stream("Claude result was not ready for completion");
+ break;
+ }
+ _finished = true;
+ return Builder.Done(PendingStop ?? StopReason.Stop);
+ }
+
+ /// Whether an error message is the API's context overflow, in any of Claude Code's wordings.
+ public static bool IsOverflow(string? message) => message is not null && Overflow().IsMatch(message);
+
+ /// Prefixes an overflow message with context_length_exceeded: so the core compacts and retries.
+ public static string NormalizeOverflowMessage(string message) =>
+ message.StartsWith("context_length_exceeded:", StringComparison.Ordinal) || !IsOverflow(message)
+ ? message
+ : $"context_length_exceeded: {message}";
+
+ // ---- init ----
+
+ private IReadOnlyList ValidateInit(JsonElement record)
+ {
+ if (SawInit) throw Init("Claude emitted duplicate initialization");
+
+ if (!record.TryGetProperty("tools", out var toolsElement) || toolsElement.ValueKind != JsonValueKind.Array)
+ throw Init("Claude initialization omitted tools");
+ var observed = new List();
+ foreach (var tool in toolsElement.EnumerateArray())
+ {
+ if (tool.ValueKind != JsonValueKind.String) throw Init("Claude initialization contained an invalid tool name");
+ observed.Add(tool.GetString()!);
+ }
+ var observedSet = observed.ToHashSet(StringComparer.Ordinal);
+ var expected = _catalog.TransportNames.ToHashSet(StringComparer.Ordinal);
+ if (observedSet.Count != observed.Count || !observedSet.SetEquals(expected))
+ throw Init($"Claude Code initialized with an unexpected tool set (expected: {FormatNames(expected)}; observed: {FormatNames(observedSet)})");
+
+ if (Str(record, "permissionMode") != "dontAsk") throw Init("Claude Code did not enter dontAsk permission mode");
+
+ if (!record.TryGetProperty("slash_commands", out var slashCommands) || slashCommands.ValueKind != JsonValueKind.Array)
+ throw Init("Claude initialization omitted customization inventories");
+ var loaded = new List();
+ foreach (var field in new[] { "plugins", "skills", "slash_commands" })
+ {
+ if (record.TryGetProperty(field, out var inventory) && inventory.ValueKind == JsonValueKind.Array && inventory.GetArrayLength() > 0)
+ loaded.Add($"{field}: {InventoryNames(inventory)}");
+ }
+ if (loaded.Count > 0) throw Init($"Claude Code loaded unexpected customizations ({string.Join("; ", loaded)})");
+
+ var apiKeySource = Str(record, "apiKeySource");
+ if (apiKeySource != "none")
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth, $"Claude Code is not using subscription auth (apiKeySource: {apiKeySource ?? "missing"})");
+
+ if (record.TryGetProperty("mcp_server_errors", out var mcpErrors) && mcpErrors.ValueKind != JsonValueKind.Null)
+ {
+ if (mcpErrors.ValueKind != JsonValueKind.Array) throw Init("Claude initialization contained invalid MCP server errors");
+ if (mcpErrors.GetArrayLength() > 0)
+ {
+ var details = mcpErrors.EnumerateArray().Select(e => $"{Str(e, "type") ?? "error"}: {Truncate(Str(e, "message") ?? "unknown", 512)}");
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, $"Claude Code reported MCP initialization errors: {string.Join("; ", details)}");
+ }
+ }
+
+ if (!record.TryGetProperty("mcp_servers", out var servers) || servers.ValueKind != JsonValueKind.Array)
+ throw Init("Claude initialization omitted MCP inventory");
+ if (_catalog.IsEmpty)
+ {
+ if (servers.GetArrayLength() > 0)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, $"Claude Code loaded an unexpected MCP server ({InventoryNames(servers)})");
+ }
+ else
+ {
+ var single = servers.GetArrayLength() == 1 ? servers[0] : (JsonElement?)null;
+ if (single is null || Str(single.Value, "name") != _catalog.ServerName || Str(single.Value, "status") != "connected")
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup,
+ $"The proposal MCP server did not initialize correctly (expected '{_catalog.ServerName}' connected; observed: {InventoryNames(servers)}{ServerStatuses(servers)})");
+ }
+
+ var model = Str(record, "model");
+ if (string.IsNullOrEmpty(model)) throw Init("Claude initialization omitted the resolved model");
+ ResponseModel = model;
+ Builder.SetResponseModel(model);
+ SawInit = true;
+ return [Builder.Start()];
+ }
+
+ // ---- stream events ----
+
+ private IReadOnlyList AcceptStreamEvent(JsonElement ev)
+ {
+ var type = Str(ev, "type");
+ if (type == "message_start")
+ {
+ if (_messageStarted) throw Stream("Claude emitted duplicate message_start");
+ var message = Obj(ev, "message") ?? throw Stream("message_start.message must be an object");
+ if (Str(message, "model") is { Length: > 0 } model)
+ {
+ ResponseModel = model;
+ Builder.SetResponseModel(model);
+ }
+ _streamMessageId = Str(message, "id");
+ if (_streamMessageId is not null) Builder.SetResponseId(_streamMessageId);
+ ApplyUsage(message);
+ _messageStarted = true;
+ return [];
+ }
+ if (_messageStopped) throw Stream($"{type ?? "untyped"} arrived after message_stop");
+ if (type == "ping") return [];
+ if (!_messageStarted) throw Stream($"{type ?? "untyped"} arrived before message_start");
+
+ switch (type)
+ {
+ case "content_block_start":
+ return StartBlock(ev);
+ case "content_block_delta":
+ return DeltaBlock(ev);
+ case "content_block_stop":
+ return EndBlock(ev);
+ case "message_delta":
+ return AcceptMessageDelta(ev);
+ case "message_stop":
+ if (_openIndex is not null) throw Stream("Claude stopped with unclosed content blocks");
+ _messageStopped = true;
+ Latch();
+ return [];
+ default:
+ throw Stream($"Unsupported Claude stream event: {type ?? "untyped"}");
+ }
+ }
+
+ private IReadOnlyList AcceptMessageDelta(JsonElement ev)
+ {
+ var delta = Obj(ev, "delta") ?? throw Stream("message_delta.delta must be an object");
+ if (delta.TryGetProperty("stop_reason", out var stop) && stop.ValueKind != JsonValueKind.Null)
+ {
+ // Claude exposes this as a string rather than a closed enum. Only tool_use and the length stops carry special
+ // meaning here, so future values are preserved as ordinary stops instead of rejecting a valid response.
+ if (stop.ValueKind != JsonValueKind.String || string.IsNullOrEmpty(stop.GetString()))
+ throw Stream($"Invalid Claude stop reason: {stop}");
+ _stopReason = stop.GetString();
+ }
+ ApplyUsage(ev);
+ Latch();
+ return [];
+ }
+
+ private void Latch()
+ {
+ if (_stopReason == "tool_use")
+ {
+ HandoffLatched = true;
+ PendingStop = StopReason.ToolUse;
+ }
+ else if (IsLengthStop(_stopReason))
+ {
+ // The API accepts input plus max_tokens beyond the window and stops at the window with
+ // model_context_window_exceeded; Claude Code answers both stops with a synthetic continuation turn.
+ HandoffLatched = true;
+ PendingStop = StopReason.Length;
+ }
+ }
+
+ private IReadOnlyList StartBlock(JsonElement ev)
+ {
+ var index = Index(ev);
+ if (_openIndex is not null)
+ throw Stream(_openIndex == index ? $"Duplicate content block index {index}" : $"Content block {index} started while block {_openIndex} is open");
+ var source = Obj(ev, "content_block") ?? throw Stream("content_block_start.content_block must be an object");
+ var events = new List();
+ _partialJson.Clear();
+ _initialInput = null;
+ _openToolName = null;
+ switch (Str(source, "type"))
+ {
+ case "text":
+ events.Add(Builder.StartText());
+ if (Str(source, "text") is { Length: > 0 } text) events.Add(Builder.AppendText(text));
+ _openKind = BlockKind.Text;
+ break;
+ case "thinking":
+ events.Add(Builder.StartThinking());
+ if (Str(source, "thinking") is { Length: > 0 } thinking) events.Add(Builder.AppendThinking(thinking));
+ if (Str(source, "signature") is { Length: > 0 } signature) Builder.AppendThinkingSignature(signature);
+ _openKind = BlockKind.Thinking;
+ break;
+ case "redacted_thinking":
+ {
+ var data = Str(source, "data");
+ if (string.IsNullOrEmpty(data)) throw Stream("Claude emitted redacted thinking without opaque data");
+ events.Add(Builder.StartThinking(redacted: true));
+ Builder.AppendThinkingSignature(data);
+ _openKind = BlockKind.Thinking;
+ break;
+ }
+ case "tool_use":
+ {
+ var qualified = Str(source, "name") ?? "";
+ if (!_catalog.TransportToTool.TryGetValue(qualified, out var toolName))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ToolUnknown, $"Claude proposed an unknown tool: {qualified}");
+ var id = Str(source, "id");
+ if (string.IsNullOrEmpty(id)) throw Stream("Claude emitted a tool without an ID");
+ if (source.TryGetProperty("input", out var input) && input.ValueKind == JsonValueKind.Object && input.EnumerateObject().Any())
+ _initialInput = input.Clone();
+ events.Add(Builder.StartToolCall(id, toolName));
+ _openToolName = toolName;
+ _openKind = BlockKind.ToolCall;
+ break;
+ }
+ default:
+ throw Stream($"Unsupported Claude content block: {Str(source, "type") ?? "untyped"}");
+ }
+ _openIndex = index;
+ return events;
+ }
+
+ private IReadOnlyList DeltaBlock(JsonElement ev)
+ {
+ var index = Index(ev);
+ if (_openIndex != index) throw Stream($"Delta for unknown content block {index}");
+ var delta = Obj(ev, "delta") ?? throw Stream("content_block_delta.delta must be an object");
+ var deltaType = Str(delta, "type");
+ switch (deltaType)
+ {
+ case "text_delta" when _openKind == BlockKind.Text && Str(delta, "text") is { } text:
+ return [Builder.AppendText(text)];
+ case "thinking_delta" when _openKind == BlockKind.Thinking && Str(delta, "thinking") is { } thinking:
+ return [Builder.AppendThinking(thinking)];
+ case "signature_delta" when _openKind == BlockKind.Thinking && Str(delta, "signature") is { } signature:
+ Builder.AppendThinkingSignature(signature);
+ return [];
+ case "input_json_delta" when _openKind == BlockKind.ToolCall && Str(delta, "partial_json") is { } partial:
+ _partialJson.Append(partial);
+ return [Builder.AppendToolCallDelta(partial)];
+ default:
+ throw Stream($"Delta {deltaType ?? "untyped"} did not match its content block");
+ }
+ }
+
+ private IReadOnlyList EndBlock(JsonElement ev)
+ {
+ var index = Index(ev);
+ if (_openIndex != index) throw Stream($"Stop for unknown content block {index}");
+ var kind = _openKind;
+ _openIndex = null;
+ _openKind = BlockKind.None;
+ switch (kind)
+ {
+ case BlockKind.Text:
+ return [Builder.EndText()];
+ case BlockKind.Thinking:
+ return [Builder.EndThinking()];
+ case BlockKind.ToolCall:
+ {
+ var raw = _partialJson.ToString();
+ _partialJson.Clear();
+ if (raw.Length > 0)
+ {
+ JsonElement parsed;
+ try
+ {
+ using var document = JsonDocument.Parse(raw);
+ parsed = document.RootElement.Clone();
+ }
+ catch (JsonException)
+ {
+ parsed = default;
+ }
+ if (parsed.ValueKind != JsonValueKind.Object)
+ {
+ // A connection drop inside streamed tool arguments truncates the JSON. Claude Code then closes the
+ // block, stops the message and announces its recovery, so hold this failure: if a recovery signal
+ // follows, the retryable interruption is the accurate explanation. The block is never published.
+ _deferredToolArguments = $"Claude emitted invalid arguments for tool {_openToolName}";
+ Builder.EndToolCall();
+ return [];
+ }
+ return [Builder.EndToolCall(parsed)];
+ }
+ return [_initialInput is { } initial ? Builder.EndToolCall(initial) : Builder.EndToolCall()];
+ }
+ default:
+ throw Stream($"Missing output block for source index {index}");
+ }
+ }
+
+ // ---- result ----
+
+ private IReadOnlyList AcceptResult(JsonElement record)
+ {
+ if (!record.TryGetProperty("is_error", out var isErrorElement) || isErrorElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
+ throw Stream("Claude result omitted a boolean is_error field");
+ var isError = isErrorElement.GetBoolean();
+ string? resultText = null;
+ if (record.TryGetProperty("result", out var resultElement) && resultElement.ValueKind != JsonValueKind.Null)
+ {
+ if (resultElement.ValueKind != JsonValueKind.String) throw Stream("Claude result contained a non-string result field");
+ resultText = resultElement.GetString();
+ }
+ SawResult = true;
+ ApplyUsage(record);
+ ApplyModelUsage(record);
+
+ if (isError)
+ {
+ if (IsExpectedHandoffTermination(record)) return [];
+ ThrowDeferredFailure();
+ var status = record.TryGetProperty("api_error_status", out var statusElement) && statusElement.ValueKind == JsonValueKind.Number
+ ? $" ({statusElement.GetRawText()})"
+ : "";
+ var detail = FirstNonEmpty(resultText, _assistantDiagnostic, ErrorsText(record), Str(record, "terminal_reason"), _rateLimitFailure) ?? "unknown error";
+ var message = $"Claude Code request failed{status}: {detail}";
+ if (IsOverflow(message))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ContextOverflow, $"context_length_exceeded: {message}");
+ throw Stream(message);
+ }
+
+ // After a latched handoff a success result belongs to Claude Code's own continuation turn; the proposal stands.
+ if (HandoffLatched) return [];
+ ThrowDeferredFailure();
+ // Only a successful result must account for every block it opened; an error result is reported as itself.
+ if (_openIndex is not null) throw Stream("Claude result arrived with unclosed content blocks");
+ if (_stopReason is null && Str(record, "stop_reason") is { Length: > 0 } resultStop) _stopReason = resultStop;
+ if (_stopReason == "tool_use") throw Stream("Claude returned success after a tool-use stop");
+
+ var events = new List();
+ if (Builder.Current.Content.Count == 0 && !string.IsNullOrEmpty(resultText))
+ {
+ events.Add(Builder.StartText());
+ events.Add(Builder.AppendText(resultText));
+ events.Add(Builder.EndText());
+ }
+ _successfulResult = true;
+ PendingStop = IsLengthStop(_stopReason) ? StopReason.Length : StopReason.Stop;
+ return events;
+ }
+
+ /// Whether an error result is only the provider's own handoff termination (SIGTERM after a tool-use or length stop).
+ private bool IsExpectedHandoffTermination(JsonElement record)
+ {
+ if (!HandoffLatched) return false;
+ if (Str(record, "subtype") != "error_during_execution") return false;
+ if (record.TryGetProperty("api_error_status", out var status) && status.ValueKind != JsonValueKind.Null) return false;
+ if (record.TryGetProperty("result", out var result) && result.ValueKind != JsonValueKind.Null) return false;
+ return true;
+ }
+
+ private void ThrowDeferredFailure()
+ {
+ var message = _deferredToolArguments;
+ if (message is null) return;
+ _deferredToolArguments = null;
+ throw Stream(message);
+ }
+
+ // ---- diagnostics ----
+
+ ///
+ /// Claude Code recovers from an API failure that arrives after this response began streaming by replaying the
+ /// request, continuing from the partial it kept, or re-requesting without streaming. The events already published
+ /// are append-only, so none of those recoveries can be published here: the provider reports one retryable failure
+ /// and the core re-runs the turn from the unchanged context in a fresh process (a prompt-cache hit).
+ ///
+ private void RejectInterruptedStream(JsonElement record, string? type, string? subtype)
+ {
+ // Before the response starts a retry is an ordinary pre-stream retry. After a tool-use or length stop the
+ // provider is already terminating Claude; records about Claude's own next request must not invalidate the proposal.
+ if (!_messageStarted || _stopReason == "tool_use" || IsLengthStop(_stopReason)) return;
+ string? cause = null;
+ if (type == "system" && subtype == "api_retry")
+ {
+ var category = Str(record, "error") is { Length: > 0 } error ? error : "unknown";
+ var status = record.TryGetProperty("error_status", out var statusElement) && statusElement.ValueKind == JsonValueKind.Number
+ ? $", HTTP {statusElement.GetRawText()}"
+ : "";
+ // A recorded rate-limit rejection already carries the reset time; it is more useful than the category alone.
+ if (category == "rate_limit" && _rateLimitFailure is not null)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.StreamInterrupted, _rateLimitFailure, retryable: true);
+ if (!RetryableInterruptions.Contains(category))
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.StreamInterrupted, $"Claude Code API request failed mid-response ({category}{status})");
+ cause = $"Claude Code began retrying: {category}{status}";
+ }
+ else if (type == "assistant")
+ {
+ if (Str(record, "error") is not null || Bool(record, "is_api_error_message") == true)
+ cause = "Claude Code reported a mid-response API error";
+ else if (!_messageStopped && Obj(record, "message") is { } message && Str(message, "id") is { } id && id != _streamMessageId)
+ // A different message id before this stream stops is Claude Code's non-streaming replacement.
+ cause = "Claude Code replaced the stream with a non-streaming request";
+ }
+ else if (type == "user" && Bool(record, "isSynthetic") == true)
+ {
+ // Claude Code's synthetic user turns ("Resume directly ...") each start another internal model turn.
+ cause = "Claude Code asked the model to continue a cut-off response";
+ }
+ // The fixed "stream ended before message_stop" phrasing is what the core's retry classifier matches.
+ if (cause is not null)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.StreamInterrupted, $"{InterruptedPhrase} ({cause})", retryable: true);
+ }
+
+ private void AcceptAssistant(JsonElement record)
+ {
+ if (_assistantDiagnostic is not null) return;
+ var error = Str(record, "error")?.Trim() ?? "";
+ var text = "";
+ if (Obj(record, "message") is { } message && message.TryGetProperty("content", out var content))
+ {
+ if (content.ValueKind == JsonValueKind.String) text = content.GetString()!.Trim();
+ else if (content.ValueKind == JsonValueKind.Array)
+ text = string.Join(" ", content.EnumerateArray()
+ .Where(block => block.ValueKind == JsonValueKind.Object && Str(block, "type") == "text")
+ .Select(block => Str(block, "text")?.Trim())
+ .Where(t => !string.IsNullOrEmpty(t)));
+ }
+ var diagnostic = string.Join(": ", new[] { error, text }.Where(part => part.Length > 0));
+ if (diagnostic.Length > 0) _assistantDiagnostic = diagnostic;
+ }
+
+ private void AcceptRateLimit(JsonElement record)
+ {
+ // Rate-limit events are advisory; malformed details must not fail an otherwise valid request.
+ if (!record.TryGetProperty("rate_limit_info", out var info) || info.ValueKind != JsonValueKind.Object) return;
+ var notice = ParseRateLimitNotice(info);
+ if (notice is null) return;
+ _notices.Add(FormatNotice(notice));
+ if (notice.Status == "rejected") _rateLimitFailure = RejectionMessage(notice);
+ }
+
+ private sealed record RateLimitNotice(
+ string Status,
+ string RateLimitType,
+ double? Utilization,
+ DateTimeOffset? ResetsAt,
+ string? OverageStatus,
+ DateTimeOffset? OverageResetsAt,
+ string? OverageDisabledReason,
+ bool IsUsingOverage);
+
+ private static RateLimitNotice? ParseRateLimitNotice(JsonElement info)
+ {
+ var primaryStatus = AlertStatus(Str(info, "status"));
+ var overageStatus = AlertStatus(Str(info, "overageStatus"));
+ var usingOverage = Bool(info, "isUsingOverage") == true;
+ // org_level_disabled is the normal repeated state without usage credits, so overage constrains a request only
+ // together with a plan rejection or actual overage use.
+ var overageBlocking = overageStatus == "rejected" && (primaryStatus == "rejected" || usingOverage);
+ if (primaryStatus is null && !overageBlocking) return null;
+ var status = primaryStatus == "rejected" || overageBlocking ? "rejected" : "allowed_warning";
+ var hasPrimary = primaryStatus is not null;
+ var overageRelevant = overageStatus is not null && (primaryStatus == "rejected" || usingOverage);
+ var rateLimitType = hasPrimary
+ ? Str(info, "rateLimitType") is { } t && !string.IsNullOrWhiteSpace(t) ? t.Trim() : "unknown"
+ : "overage";
+ var resetsAt = Timestamp(info, hasPrimary ? "resetsAt" : "overageResetsAt");
+ var overageReset = Timestamp(info, "overageResetsAt");
+ var utilization = info.TryGetProperty("utilization", out var u) && u.ValueKind == JsonValueKind.Number && u.TryGetDouble(out var value) && value is >= 0 and <= 1
+ ? value
+ : (double?)null;
+ var reason = Str(info, "overageDisabledReason") is { } r && !string.IsNullOrWhiteSpace(r) ? r.Trim() : null;
+ return new RateLimitNotice(
+ status,
+ rateLimitType,
+ hasPrimary ? utilization : null,
+ resetsAt,
+ overageRelevant ? overageStatus : null,
+ overageRelevant && hasPrimary ? overageReset : null,
+ overageRelevant ? reason : null,
+ usingOverage);
+ }
+
+ private static string RejectionMessage(RateLimitNotice notice)
+ {
+ var reason = notice.OverageDisabledReason is null ? "" : $"; {notice.OverageDisabledReason}";
+ var reset = notice.ResetsAt is null ? "" : $"; resets at {notice.ResetsAt.Value.UtcDateTime.ToString("O", CultureInfo.InvariantCulture)}";
+ return $"Claude rate limit rejected ({notice.RateLimitType}){reason}{reset}";
+ }
+
+ private static string FormatNotice(RateLimitNotice notice)
+ {
+ var reset = notice.ResetsAt is null ? "" : $"; resets at {notice.ResetsAt.Value.ToLocalTime().ToString("g", CultureInfo.CurrentCulture)}";
+ var overageReset = notice.OverageResetsAt is null ? "" : $"; overage resets at {notice.OverageResetsAt.Value.ToLocalTime().ToString("g", CultureInfo.CurrentCulture)}";
+ var overage = notice.OverageStatus is null ? "" : $"; overage {notice.OverageStatus}{(notice.OverageDisabledReason is null ? "" : $" ({notice.OverageDisabledReason})")}";
+ var usingOverage = notice.IsUsingOverage ? "; using overage" : "";
+ if (notice.Status == "rejected")
+ return $"[claude-code] Claude rate limited ({notice.RateLimitType}){reset}{overageReset}{overage}{usingOverage}";
+ var usage = notice.Utilization is null ? "usage is approaching the limit" : $"{Math.Floor(notice.Utilization.Value * 100)}% used";
+ return $"[claude-code] Claude rate limit warning: {usage} ({notice.RateLimitType}){reset}{overageReset}{overage}{usingOverage}";
+ }
+
+ private static string? AlertStatus(string? value) => value is "allowed_warning" or "rejected" ? value : null;
+
+ private static DateTimeOffset? Timestamp(JsonElement info, string name)
+ {
+ if (!info.TryGetProperty(name, out var element) || element.ValueKind != JsonValueKind.Number || !element.TryGetDouble(out var value) || value <= 0 || double.IsInfinity(value))
+ return null;
+ // The current wire value is Unix seconds; a plausible epoch-millisecond value is not converted twice.
+ var milliseconds = value >= EpochMillisecondsThreshold ? value : value * 1000;
+ try
+ {
+ return DateTimeOffset.FromUnixTimeMilliseconds((long)milliseconds);
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return null;
+ }
+ }
+
+ // ---- usage ----
+
+ private void ApplyUsage(JsonElement container)
+ {
+ if (Obj(container, "usage") is not { } usage) return;
+ if (Int(usage, "input_tokens") is { } input) _input = input;
+ if (Int(usage, "output_tokens") is { } output) _output = output;
+ if (Int(usage, "cache_read_input_tokens") is { } cacheRead) _cacheRead = cacheRead;
+ if (Int(usage, "cache_creation_input_tokens") is { } cacheWrite) _cacheWrite = cacheWrite;
+ if (Obj(usage, "output_tokens_details") is { } details && Int(details, "thinking_tokens") is { } reasoning) _reasoning = reasoning;
+ Builder.SetUsage(new Usage(_input, _output, _cacheRead, _cacheWrite, _reasoning));
+ }
+
+ private void ApplyModelUsage(JsonElement record)
+ {
+ if (Obj(record, "modelUsage") is not { } modelUsage) return;
+ foreach (var entry in modelUsage.EnumerateObject())
+ {
+ if (entry.Value.ValueKind != JsonValueKind.Object) continue;
+ ServedContextWindow ??= Int(entry.Value, "contextWindow");
+ ServedMaxOutputTokens ??= Int(entry.Value, "maxOutputTokens");
+ }
+ }
+
+ // ---- helpers ----
+
+ private static bool IsLengthStop(string? reason) => reason is "max_tokens" or "model_context_window_exceeded";
+
+ private static ClaudeCodeException Stream(string message) => new(ClaudeCodeException.Codes.ProtocolStream, message);
+
+ private static ClaudeCodeException Init(string message) => new(ClaudeCodeException.Codes.ProtocolInit, message);
+
+ private static int Index(JsonElement ev)
+ {
+ if (!ev.TryGetProperty("index", out var index) || index.ValueKind != JsonValueKind.Number || !index.TryGetInt32(out var value) || value < 0)
+ throw Stream($"Invalid Claude content index: {(ev.TryGetProperty("index", out var raw) ? raw.ToString() : "missing")}");
+ return value;
+ }
+
+ private static string? Str(JsonElement obj, string name) =>
+ obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
+
+ private static JsonElement? Obj(JsonElement obj, string name) =>
+ obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Object ? value : null;
+
+ private static bool? Bool(JsonElement obj, string name) =>
+ obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False ? value.GetBoolean() : null;
+
+ private static int? Int(JsonElement obj, string name)
+ {
+ if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Number) return null;
+ if (value.TryGetInt32(out var i)) return i;
+ return value.TryGetDouble(out var d) && double.IsFinite(d) ? (int)Math.Clamp(d, int.MinValue, int.MaxValue) : null;
+ }
+
+ private static string? FirstNonEmpty(params string?[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v))?.Trim();
+
+ private static string? ErrorsText(JsonElement record)
+ {
+ if (!record.TryGetProperty("errors", out var errors) || errors.ValueKind != JsonValueKind.Array) return null;
+ var text = string.Join("; ", errors.EnumerateArray().Where(e => e.ValueKind == JsonValueKind.String).Select(e => e.GetString()!.Trim()).Where(s => s.Length > 0));
+ return text.Length == 0 ? null : text;
+ }
+
+ private static string RecordKind(string? type, string? subtype)
+ {
+ static string Part(string? value) => value is null ? "" : Truncate(Sanitize().Replace(value, ""), 48);
+ var t = Part(type);
+ var s = Part(subtype);
+ if (t.Length == 0) t = "untyped";
+ return s.Length == 0 ? t : $"{t}/{s}";
+ }
+
+ private static string InventoryNames(JsonElement entries)
+ {
+ var names = entries.EnumerateArray().Take(5).Select(entry =>
+ {
+ var name = entry.ValueKind == JsonValueKind.String ? entry.GetString() : Str(entry, "name");
+ if (string.IsNullOrEmpty(name)) return "unnamed";
+ return name.Contains('/') || name.Contains('\\') ? "" : Truncate(name, 64);
+ });
+ var count = entries.GetArrayLength();
+ return $"{string.Join(", ", names)}{(count > 5 ? $", and {count - 5} more" : "")}";
+ }
+
+ private static string ServerStatuses(JsonElement servers)
+ {
+ var statuses = servers.EnumerateArray().Select(s => Str(s, "status")).Where(s => s is not null).Distinct().ToList();
+ return statuses.Count == 0 ? "" : $" [{string.Join(", ", statuses)}]";
+ }
+
+ private static string FormatNames(IEnumerable names)
+ {
+ var joined = string.Join(", ", names.OrderBy(n => n, StringComparer.Ordinal));
+ return joined.Length == 0 ? "none" : joined;
+ }
+
+ private static string Truncate(string value, int max) => value.Length <= max ? value : value[..max];
+
+ [GeneratedRegex(@"prompt is too long|prompt too long|context window exceeded|context length exceeded|context_length_exceeded|input length", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex Overflow();
+
+ [GeneratedRegex(@"[^\w.-]", RegexOptions.CultureInvariant)]
+ private static partial Regex Sanitize();
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ToolCatalog.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ToolCatalog.cs
new file mode 100644
index 0000000..a368843
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ToolCatalog.cs
@@ -0,0 +1,145 @@
+using System.Buffers;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Volcanic.AgenticCore.Abstractions.Providers;
+
+namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+///
+/// The active tools as the proposal MCP server advertises them. Claude Code names an MCP tool
+/// mcp__<server>__<tool> after replacing every character outside [A-Za-z0-9_-]
+/// with _, so any other name gets a digest alias; otherwise its initialisation would report a
+/// name this catalogue never offered.
+///
+public sealed partial class ToolCatalog
+{
+ public const int MaxTools = 256;
+ public const int MaxCatalogBytes = 2 * 1024 * 1024;
+ public const string DefaultServerName = "volcanic";
+ private const int MaxAliasLength = 48;
+
+ private static readonly JsonWriterOptions WriterOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
+
+ public ToolCatalog(IReadOnlyList tools, string serverName = DefaultServerName)
+ {
+ ArgumentNullException.ThrowIfNull(tools);
+ if (string.IsNullOrWhiteSpace(serverName) || !SafeName().IsMatch(serverName))
+ throw new ArgumentException("MCP server names must match [A-Za-z0-9_-]{1,48}.", nameof(serverName));
+ if (tools.Count > MaxTools)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolInit, $"At most {MaxTools} active tools are supported");
+ foreach (var tool in tools)
+ {
+ if (tool is null || string.IsNullOrWhiteSpace(tool.Name) || tool.Description is null)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Context contained an invalid active tool");
+ if (tool.Parameters.ValueKind != JsonValueKind.Object)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"Active tool schema for '{tool.Name}' must be an object");
+ }
+
+ ServerName = serverName;
+ var sorted = tools.OrderBy(t => t.Name, StringComparer.Ordinal).ToList();
+ for (var i = 1; i < sorted.Count; i++)
+ {
+ if (sorted[i - 1].Name == sorted[i].Name)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"Duplicate active tool: {sorted[i].Name}");
+ }
+
+ var used = new HashSet(StringComparer.Ordinal);
+ var transportToTool = new Dictionary(StringComparer.Ordinal);
+ var toolToTransport = new Dictionary(StringComparer.Ordinal);
+ var aliases = new List(sorted.Count);
+ var transportNames = new List(sorted.Count);
+ foreach (var tool in sorted)
+ {
+ var alias = UniqueAlias(tool.Name, used);
+ var transport = $"{TransportPrefix}{alias}";
+ aliases.Add(alias);
+ transportNames.Add(transport);
+ transportToTool[transport] = tool.Name;
+ toolToTransport[tool.Name] = transport;
+ }
+
+ Tools = sorted;
+ Aliases = aliases;
+ TransportNames = transportNames;
+ TransportToTool = transportToTool;
+ ToolToTransport = toolToTransport;
+ Json = BuildJson(sorted, aliases);
+ }
+
+ public string ServerName { get; }
+
+ /// mcp__<server>__.
+ public string TransportPrefix => $"mcp__{ServerName}__";
+
+ /// Active tools sorted by name.
+ public IReadOnlyList Tools { get; }
+
+ /// Alias of each tool in order.
+ public IReadOnlyList Aliases { get; }
+
+ /// Transport name of each tool in order; exactly what Claude Code's init record must list.
+ public IReadOnlyList TransportNames { get; }
+
+ public IReadOnlyDictionary TransportToTool { get; }
+
+ public IReadOnlyDictionary ToolToTransport { get; }
+
+ public int Count => Tools.Count;
+
+ public bool IsEmpty => Tools.Count == 0;
+
+ /// The catalogue file content: a JSON array of {name, description, inputSchema}.
+ public string Json { get; }
+
+ public string ToJson() => Json;
+
+ /// The alias for a tool name before collision suffixing.
+ public static string AliasFor(string name) =>
+ SafeName().IsMatch(name) ? name : $"tool_{Digest16(name)}";
+
+ /// First 16 hex characters of SHA-256 of the UTF-8 name.
+ public static string Digest16(string value) =>
+ Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..16];
+
+ private static string UniqueAlias(string name, HashSet used)
+ {
+ var candidate = AliasFor(name);
+ var stem = candidate;
+ var suffix = 2;
+ while (used.Contains(candidate))
+ {
+ var tail = $"_{suffix++}";
+ candidate = stem[..Math.Min(stem.Length, MaxAliasLength - tail.Length)] + tail;
+ }
+ used.Add(candidate);
+ return candidate;
+ }
+
+ private static string BuildJson(IReadOnlyList tools, IReadOnlyList aliases)
+ {
+ var buffer = new ArrayBufferWriter();
+ using (var writer = new Utf8JsonWriter(buffer, WriterOptions))
+ {
+ writer.WriteStartArray();
+ for (var i = 0; i < tools.Count; i++)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("name", aliases[i]);
+ writer.WriteString("description", tools[i].Description);
+ writer.WritePropertyName("inputSchema");
+ tools[i].Parameters.WriteTo(writer);
+ writer.WriteEndObject();
+ }
+ writer.WriteEndArray();
+ }
+ if (buffer.WrittenCount + 1 > MaxCatalogBytes)
+ throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolInit, $"Tool catalog exceeds {MaxCatalogBytes} bytes");
+ return Encoding.UTF8.GetString(buffer.WrittenSpan);
+ }
+
+ [GeneratedRegex("^[A-Za-z0-9_-]{1,48}$", RegexOptions.CultureInvariant)]
+ private static partial Regex SafeName();
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/BridgeTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/BridgeTests.cs
new file mode 100644
index 0000000..5341343
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/BridgeTests.cs
@@ -0,0 +1,187 @@
+using System.Text.Json;
+using BridgeServer = Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.Bridge;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
+
+public class BridgeTests
+{
+ private const string Catalog = "[{\"name\":\"probe\",\"description\":\"probe\",\"inputSchema\":{\"type\":\"object\"}}]";
+
+ private sealed class Sandbox : IDisposable
+ {
+ public string Directory { get; } = System.IO.Directory.CreateTempSubdirectory("bridge-test-").FullName;
+ public string CatalogPath => Path.Combine(Directory, "tools.json");
+ public string ReadyPath => Path.Combine(Directory, "ready");
+ public string ViolationPath => Path.Combine(Directory, "violation");
+
+ public Sandbox(string catalog = Catalog) => File.WriteAllText(CatalogPath, catalog);
+
+ public Dictionary Env => new(StringComparer.OrdinalIgnoreCase)
+ {
+ [BridgeServer.CatalogVariable] = CatalogPath,
+ [BridgeServer.ReadyVariable] = ReadyPath,
+ [BridgeServer.ViolationVariable] = ViolationPath,
+ };
+
+ public void Dispose() => System.IO.Directory.Delete(Directory, recursive: true);
+ }
+
+ private static (int Code, IReadOnlyList Responses, string Stderr) Run(string input, IReadOnlyDictionary env)
+ {
+ var output = new StringWriter();
+ var error = new StringWriter();
+ var code = BridgeServer.Run(new StringReader(input), output, error, env);
+ var responses = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(line => JsonSerializer.Deserialize(line)).ToList();
+ return (code, responses, error.ToString());
+ }
+
+ private static string Request(int id, string method, string parameters = "{}") =>
+ $"{{\"jsonrpc\":\"2.0\",\"id\":{id},\"method\":\"{method}\",\"params\":{parameters}}}";
+
+ [Fact]
+ public void Initialize_returns_protocol_version_and_tool_capability()
+ {
+ using var sandbox = new Sandbox();
+ var (code, responses, _) = Run(Request(1, "initialize") + "\n", sandbox.Env);
+
+ Assert.Equal(0, code);
+ var response = Assert.Single(responses);
+ Assert.Equal("2.0", response.GetProperty("jsonrpc").GetString());
+ Assert.Equal(1, response.GetProperty("id").GetInt32());
+ var result = response.GetProperty("result");
+ Assert.Equal("2025-11-25", result.GetProperty("protocolVersion").GetString());
+ Assert.Equal(JsonValueKind.Object, result.GetProperty("capabilities").GetProperty("tools").ValueKind);
+ Assert.Equal("volcanic-tool-proposals", result.GetProperty("serverInfo").GetProperty("name").GetString());
+ Assert.Equal("1.0.0", result.GetProperty("serverInfo").GetProperty("version").GetString());
+ Assert.False(File.Exists(sandbox.ReadyPath));
+ }
+
+ [Fact]
+ public void ToolsList_returns_catalogue_and_creates_ready_marker()
+ {
+ using var sandbox = new Sandbox();
+ var (code, responses, _) = Run(Request(1, "initialize") + "\n" + Request(2, "tools/list") + "\n", sandbox.Env);
+
+ Assert.Equal(0, code);
+ Assert.Equal(2, responses.Count);
+ var tools = responses[1].GetProperty("result").GetProperty("tools");
+ Assert.Equal(1, tools.GetArrayLength());
+ Assert.Equal("probe", tools[0].GetProperty("name").GetString());
+ Assert.Equal("object", tools[0].GetProperty("inputSchema").GetProperty("type").GetString());
+ Assert.True(File.Exists(sandbox.ReadyPath));
+ Assert.Equal("ready\n", File.ReadAllText(sandbox.ReadyPath));
+ Assert.False(File.Exists(sandbox.ViolationPath));
+ }
+
+ [Fact]
+ public void ToolsCall_creates_violation_marker_and_refuses_execution()
+ {
+ using var sandbox = new Sandbox();
+ var (code, responses, _) = Run(Request(3, "tools/call", "{\"name\":\"probe\",\"arguments\":{}}") + "\n" + Request(4, "tools/call") + "\n", sandbox.Env);
+
+ Assert.Equal(0, code);
+ Assert.Equal(2, responses.Count);
+ foreach (var response in responses)
+ {
+ var error = response.GetProperty("error");
+ Assert.Equal(-32001, error.GetProperty("code").GetInt32());
+ Assert.Contains("never executes", error.GetProperty("message").GetString());
+ }
+ Assert.True(File.Exists(sandbox.ViolationPath));
+ }
+
+ [Fact]
+ public void Unknown_method_is_method_not_found()
+ {
+ using var sandbox = new Sandbox();
+ var (code, responses, _) = Run(Request(5, "future/method") + "\n", sandbox.Env);
+
+ Assert.Equal(0, code);
+ var error = Assert.Single(responses).GetProperty("error");
+ Assert.Equal(-32601, error.GetProperty("code").GetInt32());
+ Assert.Equal("Method not found: future/method", error.GetProperty("message").GetString());
+ }
+
+ [Fact]
+ public void Notification_without_id_is_ignored()
+ {
+ using var sandbox = new Sandbox();
+ var (code, responses, _) = Run("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\"}\n", sandbox.Env);
+
+ Assert.Equal(0, code);
+ Assert.Empty(responses);
+ // A notification-style tools/list still marks readiness.
+ Assert.True(File.Exists(sandbox.ReadyPath));
+ }
+
+ [Fact]
+ public void Invalid_json_and_non_object_requests_are_rejected_without_stopping()
+ {
+ using var sandbox = new Sandbox();
+ var (code, responses, _) = Run("not json\n[1,2]\n" + Request(1, "initialize") + "\n", sandbox.Env);
+
+ Assert.Equal(0, code);
+ Assert.Equal(3, responses.Count);
+ Assert.Equal(-32700, responses[0].GetProperty("error").GetProperty("code").GetInt32());
+ Assert.Equal(JsonValueKind.Null, responses[0].GetProperty("id").ValueKind);
+ Assert.Equal(-32600, responses[1].GetProperty("error").GetProperty("code").GetInt32());
+ Assert.True(responses[2].TryGetProperty("result", out _));
+ }
+
+ [Fact]
+ public void Missing_or_invalid_catalogue_returns_2()
+ {
+ var (code, responses, stderr) = Run(Request(1, "initialize") + "\n", new Dictionary());
+ Assert.Equal(2, code);
+ Assert.Empty(responses);
+ Assert.Contains(BridgeServer.CatalogVariable, stderr);
+
+ using var invalid = new Sandbox("not json");
+ var (invalidCode, _, invalidStderr) = Run(Request(1, "initialize") + "\n", invalid.Env);
+ Assert.Equal(2, invalidCode);
+ Assert.Contains("invalid tool catalog", invalidStderr);
+
+ using var notArray = new Sandbox("{\"tools\":[]}");
+ var (objectCode, _, objectStderr) = Run("", notArray.Env);
+ Assert.Equal(2, objectCode);
+ Assert.Contains("must be an array", objectStderr);
+ }
+
+ [Fact]
+ public void Crlf_lines_and_unterminated_last_line_are_accepted()
+ {
+ using var sandbox = new Sandbox();
+ var (code, responses, _) = Run(Request(1, "initialize", "{\"note\":\"雪\"}") + "\r\n" + Request(2, "tools/list"), sandbox.Env);
+
+ Assert.Equal(0, code);
+ Assert.Equal(2, responses.Count);
+ Assert.Equal(1, responses[0].GetProperty("id").GetInt32());
+ Assert.Equal(2, responses[1].GetProperty("id").GetInt32());
+ Assert.True(File.Exists(sandbox.ReadyPath));
+ }
+
+ [Fact]
+ public void Request_over_one_megabyte_fails_with_parse_error_and_exit_1()
+ {
+ using var sandbox = new Sandbox();
+ var empty = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"future\",\"pad\":\"\"}";
+ var exact = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"future\",\"pad\":\"" + new string('x', BridgeServer.MaxRequestBytes - empty.Length) + "\"}";
+ Assert.Equal(BridgeServer.MaxRequestBytes, System.Text.Encoding.UTF8.GetByteCount(exact));
+
+ // Exactly the limit is accepted (and answered as an unknown method).
+ var (okCode, okResponses, _) = Run(exact + "\n", sandbox.Env);
+ Assert.Equal(0, okCode);
+ Assert.Equal(-32601, Assert.Single(okResponses).GetProperty("error").GetProperty("code").GetInt32());
+
+ // One byte over is fatal, even without a terminating newline.
+ var (code, responses, _) = Run(new string('x', BridgeServer.MaxRequestBytes + 1), sandbox.Env);
+ Assert.Equal(1, code);
+ var error = Assert.Single(responses).GetProperty("error");
+ Assert.Equal(-32700, error.GetProperty("code").GetInt32());
+ Assert.Contains("exceeds the bridge limit", error.GetProperty("message").GetString());
+
+ var (terminatedCode, terminatedResponses, _) = Run(new string('x', BridgeServer.MaxRequestBytes + 1) + "\n" + Request(9, "initialize") + "\n", sandbox.Env);
+ Assert.Equal(1, terminatedCode);
+ Assert.Single(terminatedResponses);
+ }
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeArgsTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeArgsTests.cs
new file mode 100644
index 0000000..63d8e83
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeArgsTests.cs
@@ -0,0 +1,131 @@
+using System.Text.Json;
+using Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
+
+public class ClaudeArgsTests
+{
+ [Fact]
+ public void Build_with_tools_and_effort_yields_the_exact_argv()
+ {
+ var input = new ClaudeArgsInput(
+ "sonnet", "medium", "/tmp/private/system-prompt.txt",
+ CatalogPath: "/tmp/private/tools.json", ViolationPath: "/tmp/private/mcp-violation", ReadyPath: "/tmp/private/mcp-ready",
+ BridgeDllPath: "/opt/ext/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll", DotnetPath: "/usr/bin/dotnet");
+
+ var args = ClaudeArgs.Build(input);
+
+ var mcp = ClaudeArgs.BuildMcpConfig(input);
+ string[] expected =
+ [
+ "-p",
+ "--setting-sources", "",
+ "--settings", "{\"disableAllHooks\":true,\"autoMemoryEnabled\":false,\"totalTokensReminder\":\"off\",\"enabledPlugins\":{\"agents-md@builtin\":false}}",
+ "--disable-slash-commands",
+ "--strict-mcp-config",
+ "--permission-mode", "dontAsk",
+ "--no-chrome",
+ "--no-session-persistence",
+ "--prompt-suggestions", "false",
+ "--mcp-config", mcp,
+ "--tools", "",
+ "--model", "sonnet",
+ "--effort", "medium",
+ "--thinking-display", "summarized",
+ "--input-format", "stream-json",
+ "--output-format", "stream-json",
+ "--include-partial-messages",
+ "--verbose",
+ "--system-prompt-file", "/tmp/private/system-prompt.txt",
+ ];
+ Assert.Equal(expected, args);
+
+ var server = JsonSerializer.Deserialize(mcp).GetProperty("mcpServers").GetProperty("volcanic");
+ Assert.Equal("/usr/bin/dotnet", server.GetProperty("command").GetString());
+ Assert.Equal(["/opt/ext/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll"], server.GetProperty("args").EnumerateArray().Select(a => a.GetString()!).ToArray());
+ var env = server.GetProperty("env");
+ Assert.Equal("/tmp/private/tools.json", env.GetProperty("VOLCANIC_CLAUDE_TOOL_CATALOG").GetString());
+ Assert.Equal("/tmp/private/mcp-violation", env.GetProperty("VOLCANIC_CLAUDE_TOOL_VIOLATION").GetString());
+ Assert.Equal("/tmp/private/mcp-ready", env.GetProperty("VOLCANIC_CLAUDE_TOOL_READY").GetString());
+ }
+
+ [Fact]
+ public void Build_without_tools_or_effort_uses_an_empty_mcp_config_and_omits_effort()
+ {
+ var args = ClaudeArgs.Build(new ClaudeArgsInput("haiku", null, "/tmp/p/system-prompt.txt"));
+
+ string[] expected =
+ [
+ "-p", "--setting-sources", "", "--settings", ClaudeArgs.Settings, "--disable-slash-commands", "--strict-mcp-config",
+ "--permission-mode", "dontAsk", "--no-chrome", "--no-session-persistence", "--prompt-suggestions", "false",
+ "--mcp-config", "{\"mcpServers\":{}}", "--tools", "", "--model", "haiku",
+ "--thinking-display", "summarized", "--input-format", "stream-json", "--output-format", "stream-json",
+ "--include-partial-messages", "--verbose", "--system-prompt-file", "/tmp/p/system-prompt.txt",
+ ];
+ Assert.Equal(expected, args);
+ Assert.DoesNotContain("--effort", args);
+ }
+
+ [Fact]
+ public void Mcp_config_honours_the_server_name_and_requires_bridge_and_dotnet()
+ {
+ var mcp = ClaudeArgs.BuildMcpConfig(new ClaudeArgsInput("sonnet", null, "/p/s.txt", CatalogPath: "/p/tools.json", BridgeDllPath: "/b.dll", DotnetPath: "dotnet", ServerName: "pi"));
+ Assert.True(JsonSerializer.Deserialize(mcp).GetProperty("mcpServers").TryGetProperty("pi", out _));
+
+ var missingBridge = Assert.Throws(() => ClaudeArgs.BuildMcpConfig(new ClaudeArgsInput("sonnet", null, "/p/s.txt", CatalogPath: "/p/tools.json", DotnetPath: "dotnet")));
+ Assert.Equal("mcp_startup", missingBridge.Code);
+ }
+
+ [Fact]
+ public void BuildEnvironment_allow_lists_forces_and_rejects()
+ {
+ var parent = new Dictionary
+ {
+ ["HOME"] = "/home/me",
+ ["PATH"] = "/usr/bin",
+ ["LANG"] = "C.UTF-8",
+ ["LC_ALL"] = "C",
+ ["LC_CTYPE"] = "C.UTF-8",
+ ["http_proxy"] = "http://proxy",
+ ["CLAUDE_CONFIG_DIR"] = "/cfg",
+ ["SECRET_TOKEN"] = "nope",
+ ["NPM_TOKEN"] = "nope",
+ ["DISABLE_COMPACT"] = "0",
+ ["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] = "1",
+ ["NULL_VALUE"] = null,
+ };
+
+ var env = ClaudeArgs.BuildEnvironment(parent, 64_000);
+
+ Assert.Equal("/home/me", env["HOME"]);
+ Assert.Equal("/usr/bin", env["PATH"]);
+ Assert.Equal("C", env["LC_ALL"]);
+ Assert.Equal("C.UTF-8", env["LC_CTYPE"]);
+ Assert.Equal("http://proxy", env["http_proxy"]);
+ Assert.Equal("/cfg", env["CLAUDE_CONFIG_DIR"]);
+ Assert.False(env.ContainsKey("SECRET_TOKEN"));
+ Assert.False(env.ContainsKey("NPM_TOKEN"));
+ Assert.False(env.ContainsKey("NULL_VALUE"));
+ Assert.Equal("1", env["CLAUDE_CODE_DISABLE_AUTO_MEMORY"]);
+ Assert.Equal("1", env["CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS"]);
+ Assert.Equal("1", env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"]);
+ Assert.Equal("1", env["DISABLE_COMPACT"]);
+ Assert.Equal("64000", env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"]);
+ }
+
+ [Theory]
+ [InlineData("ANTHROPIC_API_KEY")]
+ [InlineData("ANTHROPIC_AUTH_TOKEN")]
+ [InlineData("ANTHROPIC_BASE_URL")]
+ [InlineData("CLAUDE_CODE_OAUTH_TOKEN")]
+ [InlineData("CLAUDE_CODE_USE_BEDROCK")]
+ [InlineData("claude_code_use_vertex")]
+ public void BuildEnvironment_rejects_api_key_and_third_party_auth(string name)
+ {
+ var parent = new Dictionary { ["HOME"] = "/home/me", [name] = "value" };
+ var ex = Assert.Throws(() => ClaudeArgs.BuildEnvironment(parent, 1000));
+ Assert.Equal("auth", ex.Code);
+ Assert.Contains(name, ex.Message);
+ Assert.Equal(name, ClaudeArgs.FindDeniedVariable(parent));
+ }
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeCodeApiTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeCodeApiTests.cs
new file mode 100644
index 0000000..1bfae76
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeCodeApiTests.cs
@@ -0,0 +1,221 @@
+using System.Diagnostics;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Volcanic.AgenticCore.Abstractions.Messages;
+using Volcanic.AgenticCore.Abstractions.Providers;
+using Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
+
+/// Drives end to end against a fake claude shell script. No test ever spawns the real CLI.
+public class ClaudeCodeApiTests : IDisposable
+{
+ private readonly string _root = Directory.CreateTempSubdirectory("claude-api-test-").FullName;
+ private readonly string _cwd;
+ private readonly SessionRegistry _registry = new();
+
+ public ClaudeCodeApiTests()
+ {
+ _cwd = Path.Combine(_root, "project");
+ Directory.CreateDirectory(_cwd);
+ _registry.Register("session-1", _cwd);
+ }
+
+ public void Dispose()
+ {
+ _registry.Remove("session-1");
+ try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
+ }
+
+ private static string BridgeDll => Path.Combine(AppContext.BaseDirectory, BridgeLocator.BridgeFileName);
+
+ /// A fake claude: touches the ready marker (found through the --system-prompt-file argument), prints the body, then runs the tail.
+ private string FakeClaude(string body, string tail = "exec sleep 30")
+ {
+ var script = Path.Combine(_root, $"fake-claude-{Guid.NewGuid():N}.sh");
+ File.WriteAllText(script, "#!/bin/sh\n" +
+ "prev=\"\"\ndir=\"\"\n" +
+ "for a in \"$@\"; do\n" +
+ " if [ \"$prev\" = \"--system-prompt-file\" ]; then dir=$(dirname \"$a\"); fi\n" +
+ " prev=\"$a\"\n" +
+ "done\n" +
+ "if [ -n \"$dir\" ]; then : > \"$dir/mcp-ready\"; fi\n" +
+ body + "\n" + tail + "\n");
+ if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ return script;
+ }
+
+ private ClaudeCodeApi Api(string claudePath) => new(_registry, new ClaudeCodeApiOptions
+ {
+ ClaudePath = claudePath,
+ BridgeDllPath = BridgeDll,
+ DotnetPath = Environment.ProcessPath ?? "dotnet",
+ GetEnv = _ => null,
+ ParentEnvironment = new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH"), ["HOME"] = _root },
+ ServerName = Fixtures.ServerName,
+ });
+
+ private static async Task> Collect(IAsyncEnumerable stream)
+ {
+ var events = new List();
+ await foreach (var e in stream) events.Add(e);
+ return events;
+ }
+
+ private static LlmContext ToolContext() => new("You are a test.", [UserMessage.FromText("run the probe")], [Fixtures.Probe]);
+
+ [Fact]
+ public async Task Tool_proposal_is_captured_and_the_process_is_terminated_at_the_latch()
+ {
+ if (!Fixtures.IsPosix) return;
+ var script = FakeClaude($"cat \"{Fixtures.FixturePath("claude-2.1.281-stream-tool-ok.jsonl")}\"");
+ var api = Api(script);
+ var stopwatch = Stopwatch.StartNew();
+
+ var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1", Reasoning = ThinkingLevel.Medium }, CancellationToken.None));
+ stopwatch.Stop();
+
+ Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(8), $"the sleeping fake claude must be terminated promptly; took {stopwatch.Elapsed}");
+ Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "ToolCallStart", "ToolCallDelta", "ToolCallEnd", "Done"], events.Select(e => e.GetType().Name).ToArray());
+ var done = Assert.IsType(events[^1]);
+ Assert.Equal(StopReason.ToolUse, done.Reason);
+ var call = Assert.Single(done.Message.ToolCalls);
+ Assert.Equal("probe", call.Name);
+ Assert.Equal("probe", call.Arguments.GetProperty("path").GetString());
+ Assert.Equal("calling", done.Message.Text);
+ Assert.Equal(20, done.Message.Usage.Input);
+ Assert.Equal("claude-code", done.Message.Provider);
+ Assert.Equal("sonnet", done.Message.Model);
+ // The private request directory is removed before the terminal event is published.
+ Assert.DoesNotContain(Directory.GetDirectories(Path.GetTempPath(), "volcanic-claude-*"),
+ d => File.Exists(Path.Combine(d, "system-prompt.txt")) && File.ReadAllText(Path.Combine(d, "system-prompt.txt")) == "You are a test.");
+ }
+
+ [Fact]
+ public async Task Exit_before_a_terminal_event_reports_the_stderr_excerpt()
+ {
+ if (!Fixtures.IsPosix) return;
+ var script = FakeClaude("echo \"boom: fake failure\" >&2", tail: "exit 1");
+ var api = Api(script);
+
+ var events = await Collect(api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
+
+ var error = Assert.IsType(Assert.Single(events));
+ Assert.Equal(StopReason.Error, error.Reason);
+ Assert.StartsWith("[process_exit]", error.Message.ErrorMessage);
+ Assert.Contains("code 1", error.Message.ErrorMessage);
+ Assert.Contains("boom: fake failure", error.Message.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task Text_only_turn_without_tools_completes_and_runs_the_hooks()
+ {
+ if (!Fixtures.IsPosix) return;
+ var lines = new[]
+ {
+ Fixtures.Init().GetRawText(),
+ "{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_t\",\"model\":\"claude-sonnet-5\",\"usage\":{\"input_tokens\":7}}}}",
+ "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}",
+ "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello there\"}}}",
+ "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_stop\",\"index\":0}}",
+ "{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":3}}}",
+ "{\"type\":\"stream_event\",\"event\":{\"type\":\"message_stop\"}}",
+ "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hello there\",\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":7,\"output_tokens\":3}}",
+ };
+ var fixture = Path.Combine(_root, "text.jsonl");
+ File.WriteAllLines(fixture, lines);
+ // Echo stdin back into a file so the request payload can be checked, then print the response.
+ var script = FakeClaude($"cat > \"{Path.Combine(_root, "stdin.json")}\"\ncat \"{fixture}\"", tail: "exit 0");
+ var api = Api(script);
+ JsonNode? payload = null;
+ var responses = new List();
+ var records = 0;
+ var options = new StreamOptions
+ {
+ SessionId = "session-1",
+ OnPayload = p => { payload = p; return ValueTask.FromResult(null); },
+ OnResponse = r => { responses.Add(r); return ValueTask.CompletedTask; },
+ OnProviderStreamEvent = _ => { records++; return ValueTask.CompletedTask; },
+ };
+
+ var events = await Collect(api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("system prompt", "hi @there"), options, CancellationToken.None));
+
+ Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "Done"], events.Select(e => e.GetType().Name).ToArray());
+ var done = Assert.IsType(events[^1]);
+ Assert.Equal(StopReason.Stop, done.Reason);
+ Assert.Equal("hello there", done.Message.Text);
+ Assert.Equal(7, done.Message.Usage.Input);
+ Assert.Equal(3, done.Message.Usage.Output);
+ Assert.Equal(lines.Length, records);
+ Assert.Equal(200, Assert.Single(responses).Status);
+
+ Assert.NotNull(payload);
+ var argv = payload["argv"]!.AsArray().Select(a => a!.GetValue()).ToList();
+ Assert.Contains("--system-prompt-file", argv);
+ Assert.Equal("sonnet", argv[argv.IndexOf("--model") + 1]);
+ Assert.DoesNotContain("--effort", argv);
+ Assert.Equal("{\"mcpServers\":{}}", argv[argv.IndexOf("--mcp-config") + 1]);
+
+ var stdin = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(_root, "stdin.json")));
+ Assert.Equal("user", stdin.GetProperty("type").GetString());
+ var blocks = stdin.GetProperty("message").GetProperty("content");
+ Assert.Equal(2, blocks.GetArrayLength());
+ Assert.DoesNotContain("@", blocks[1].GetProperty("text").GetString());
+ Assert.Contains("hi \\u0040there", blocks[1].GetProperty("text").GetString());
+ Assert.True(blocks[1].TryGetProperty("cache_control", out _));
+ }
+
+ [Fact]
+ public async Task Cancellation_terminates_the_process_and_reports_aborted()
+ {
+ if (!Fixtures.IsPosix) return;
+ var script = FakeClaude($"echo '{Fixtures.Init().GetRawText()}'");
+ var api = Api(script);
+ using var cts = new CancellationTokenSource();
+ var events = new List();
+ var stopwatch = Stopwatch.StartNew();
+
+ await foreach (var e in api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple(null, "hi"), new StreamOptions { SessionId = "session-1" }, cts.Token))
+ {
+ events.Add(e);
+ if (e is AssistantMessageEvent.Start) cts.CancelAfter(100);
+ }
+ stopwatch.Stop();
+
+ Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(8), $"took {stopwatch.Elapsed}");
+ Assert.Equal(["Start", "Error"], events.Select(e => e.GetType().Name).ToArray());
+ var error = Assert.IsType(events[^1]);
+ Assert.Equal(StopReason.Aborted, error.Reason);
+ Assert.Equal("aborted", error.Message.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task Missing_executable_and_unresolvable_session_fail_without_throwing()
+ {
+ var missing = new ClaudeCodeApi(_registry, new ClaudeCodeApiOptions { ClaudePath = null, GetEnv = _ => null });
+ var events = await Collect(missing.StreamAsync(Fixtures.Sonnet, LlmContext.Simple(null, "hi"), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
+ var error = Assert.IsType(Assert.Single(events));
+ Assert.StartsWith("[executable_missing]", error.Message.ErrorMessage);
+
+ if (!Fixtures.IsPosix) return;
+ var api = Api(FakeClaude("", tail: "exit 0"));
+ var unresolved = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "unknown-session" }, CancellationToken.None));
+ Assert.StartsWith("[working_directory]", Assert.IsType(Assert.Single(unresolved)).Message.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task Api_key_in_the_parent_environment_is_refused()
+ {
+ if (!Fixtures.IsPosix) return;
+ var api = new ClaudeCodeApi(_registry, new ClaudeCodeApiOptions
+ {
+ ClaudePath = FakeClaude("", tail: "exit 0"),
+ GetEnv = _ => null,
+ ParentEnvironment = new Dictionary { ["ANTHROPIC_API_KEY"] = "sk-x" },
+ });
+
+ var events = await Collect(api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple(null, "hi"), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
+
+ Assert.StartsWith("[auth]", Assert.IsType(Assert.Single(events)).Message.ErrorMessage);
+ }
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeInstallationTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeInstallationTests.cs
new file mode 100644
index 0000000..99fe9a9
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeInstallationTests.cs
@@ -0,0 +1,109 @@
+using Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
+
+public class ClaudeInstallationTests
+{
+ [Fact]
+ public void Captured_help_has_every_required_flag()
+ {
+ Assert.Empty(ClaudeInstallation.MissingFlags(Fixtures.HelpText));
+ Assert.Contains("--print", ClaudeInstallation.RequiredFlags);
+ Assert.Contains("--system-prompt", ClaudeInstallation.RequiredFlags);
+ }
+
+ [Fact]
+ public void Truncated_help_reports_the_missing_flags()
+ {
+ var lines = Fixtures.HelpText.Split('\n');
+ var truncated = string.Join('\n', lines.Take(60));
+
+ var missing = ClaudeInstallation.MissingFlags(truncated);
+
+ Assert.NotEmpty(missing);
+ Assert.Contains("--verbose", missing);
+ Assert.Contains("--tools", missing);
+ Assert.DoesNotContain("--add-dir", missing);
+ Assert.Empty(ClaudeInstallation.MissingFlags(" -p, --print x\n" + string.Join('\n', ClaudeInstallation.RequiredFlags.Select(f => $" {f} help"))));
+ // A longer flag with the same prefix does not satisfy the check.
+ Assert.Contains("--tools", ClaudeInstallation.MissingFlags(" --toolset "));
+ }
+
+ [Fact]
+ public void Locate_prefers_the_environment_override_then_searches_path()
+ {
+ Assert.Equal("/opt/claude/bin/claude", ClaudeInstallation.Locate(name => name == ClaudeInstallation.PathVariable ? " /opt/claude/bin/claude " : null));
+ Assert.Null(ClaudeInstallation.Locate(_ => null));
+
+ var directory = Directory.CreateTempSubdirectory("locate-test-").FullName;
+ try
+ {
+ var name = OperatingSystem.IsWindows() ? "claude.cmd" : "claude";
+ var executable = Path.Combine(directory, name);
+ File.WriteAllText(executable, "#!/bin/sh\nexit 0\n");
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(executable, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ Assert.Null(ClaudeInstallation.Locate(n => n == "PATH" ? directory : null));
+ File.SetUnixFileMode(executable, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+ Assert.Equal(executable, ClaudeInstallation.Locate(n => n switch
+ {
+ "PATH" => $"{Path.Combine(directory, "missing")}{Path.PathSeparator}{directory}",
+ "PATHEXT" => ".COM;.EXE;.BAT;.CMD",
+ _ => null,
+ }));
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task Inspect_reads_version_auth_and_help_from_the_cli()
+ {
+ if (!Fixtures.IsPosix) return;
+ var directory = Directory.CreateTempSubdirectory("inspect-test-").FullName;
+ try
+ {
+ var script = Path.Combine(directory, "claude");
+ File.WriteAllText(script, $$"""
+ #!/bin/sh
+ case "$1" in
+ --version) echo "2.1.280 (Claude Code)";;
+ auth) echo '{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"Pro"}';;
+ --help) cat "{{Fixtures.FixturePath("claude-2.1.281-help.txt")}}";;
+ *) echo "unexpected: $*" >&2; exit 3;;
+ esac
+
+ """);
+ if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+
+ var info = await ClaudeInstallation.InspectAsync(script, CancellationToken.None);
+
+ Assert.Equal(script, info.Path);
+ Assert.Equal("2.1.280", info.Version);
+ Assert.True(info.LoggedIn);
+ Assert.Equal("claude.ai", info.AuthMethod);
+ Assert.Equal("firstParty", info.ApiProvider);
+ Assert.Equal("pro", info.SubscriptionType);
+ Assert.Empty(info.MissingFlags);
+ Assert.DoesNotContain(info.Warnings, w => w.Contains("version"));
+
+ File.WriteAllText(script, "#!/bin/sh\necho 'claude: not logged in'\nexit 1\n");
+ var broken = await ClaudeInstallation.InspectAsync(script, CancellationToken.None);
+ Assert.Null(broken.Version);
+ Assert.False(broken.LoggedIn);
+ Assert.Equal(ClaudeInstallation.RequiredFlags.Count, broken.MissingFlags.Count);
+ Assert.NotEmpty(broken.Warnings);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+
+ var missing = await Assert.ThrowsAsync(() => ClaudeInstallation.InspectAsync(Path.Combine(directory, "nope"), CancellationToken.None));
+ Assert.Equal("executable_missing", missing.Code);
+ }
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeProcessTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeProcessTests.cs
new file mode 100644
index 0000000..c34e461
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeProcessTests.cs
@@ -0,0 +1,78 @@
+using System.Diagnostics;
+using Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
+
+public class ClaudeProcessTests
+{
+ private static ClaudeProcessOptions Sleep(TimeSpan idle, TimeSpan total) => new(
+ "/bin/sh", ["-c", "echo started; exec sleep 30"], Path.GetTempPath(),
+ new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH") ?? "/usr/bin:/bin" }, idle, total);
+
+ [Fact]
+ public async Task Terminate_sends_sigterm_first_and_reports_143()
+ {
+ if (!Fixtures.IsPosix) return;
+ using var process = ClaudeProcess.Start(Sleep(TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(30)));
+ var line = new byte[8];
+ Assert.True(await process.Stdout.ReadAsync(line) > 0);
+ var stopwatch = Stopwatch.StartNew();
+
+ await process.TerminateAsync();
+ var exitCode = await process.WaitForExitAsync(CancellationToken.None);
+
+ Assert.True(process.TerminationRequested);
+ Assert.True(process.HasExited);
+ Assert.Equal(143, exitCode);
+ Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(3), $"took {stopwatch.Elapsed}");
+ Assert.Null(process.TimeoutError);
+ }
+
+ [Fact]
+ public async Task Idle_timeout_terminates_the_child_and_records_a_timeout_error()
+ {
+ if (!Fixtures.IsPosix) return;
+ using var process = ClaudeProcess.Start(Sleep(TimeSpan.FromMilliseconds(300), TimeSpan.FromMinutes(30)));
+
+ var exitCode = await process.WaitForExitAsync(CancellationToken.None);
+
+ Assert.NotNull(process.TimeoutError);
+ Assert.Equal("timeout", process.TimeoutError.Code);
+ Assert.Contains("no protocol activity", process.TimeoutError.Message);
+ Assert.NotEqual(0, exitCode);
+ }
+
+ [Fact]
+ public async Task Stderr_tail_is_bounded_and_sanitised()
+ {
+ if (!Fixtures.IsPosix) return;
+ var options = new ClaudeProcessOptions("/bin/sh", ["-c", "printf 'x%.0s' $(seq 1 70000) >&2; printf 'tail\\tline\\n' >&2; exit 3"], Path.GetTempPath(),
+ new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH") ?? "/usr/bin:/bin" }, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
+ using var process = ClaudeProcess.Start(options);
+
+ var exitCode = await process.WaitForExitAsync(CancellationToken.None);
+
+ Assert.Equal(3, exitCode);
+ var excerpt = process.StderrExcerpt(maxChars: 20);
+ Assert.EndsWith("tail line", excerpt);
+ Assert.True(excerpt.Length <= 20);
+ Assert.True(process.StderrExcerpt(maxChars: 100_000).Length <= ClaudeProcess.MaxStderrChars);
+ }
+
+ [Fact]
+ public void Missing_executable_is_reported_as_executable_missing()
+ {
+ var ex = Assert.Throws(() => ClaudeProcess.Start(new ClaudeProcessOptions(
+ Path.Combine(Path.GetTempPath(), "no-such-claude"), [], Path.GetTempPath(), new Dictionary(), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1))));
+ Assert.Equal("executable_missing", ex.Code);
+ }
+
+ [Fact]
+ public void Timeout_settings_parse_or_fail_loudly()
+ {
+ Assert.Equal(TimeSpan.FromMinutes(5), ClaudeProcess.TimeoutSetting(_ => null, ClaudeProcess.IdleTimeoutVariable, TimeSpan.FromMinutes(5)));
+ Assert.Equal(TimeSpan.FromMilliseconds(1500), ClaudeProcess.TimeoutSetting(_ => " 1500 ", ClaudeProcess.IdleTimeoutVariable, TimeSpan.FromMinutes(5)));
+ Assert.Equal("timeout", Assert.Throws(() => ClaudeProcess.TimeoutSetting(_ => "-1", ClaudeProcess.IdleTimeoutVariable, TimeSpan.Zero)).Code);
+ Assert.Equal("timeout", Assert.Throws(() => ClaudeProcess.TimeoutSetting(_ => "soon", ClaudeProcess.IdleTimeoutVariable, TimeSpan.Zero)).Code);
+ }
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ContextSerializerTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ContextSerializerTests.cs
new file mode 100644
index 0000000..4d106d6
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ContextSerializerTests.cs
@@ -0,0 +1,216 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Volcanic.AgenticCore.Abstractions.Messages;
+using Volcanic.AgenticCore.Abstractions.Providers;
+using Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
+
+public class ContextSerializerTests : IDisposable
+{
+ private readonly ClaudeImageStore _images = new();
+
+ public void Dispose() => _images.Dispose();
+
+ private static AssistantMessage Assistant(params ContentBlock[] content) => new()
+ {
+ Content = content,
+ Provider = "claude-code",
+ Api = "claude-code-headless",
+ Model = "sonnet",
+ StopReason = StopReason.Stop,
+ Usage = new Usage(1, 1, 0, 0),
+ };
+
+ private static ImageContent Pixels(string text) => new(Convert.ToBase64String(Encoding.UTF8.GetBytes(text)), "image/png");
+
+ private static IReadOnlyList Blocks(JsonObject stdin) =>
+ stdin["message"]!["content"]!.AsArray().Select(b => JsonSerializer.Deserialize(b!.ToJsonString())).ToList();
+
+ private static JsonElement ParseRecord(JsonElement block) => JsonSerializer.Deserialize(block.GetProperty("text").GetString()!);
+
+ [Fact]
+ public void Header_then_one_record_per_message_inside_one_user_message()
+ {
+ var context = new LlmContext(
+ "system is passed separately",
+ [
+ UserMessage.FromText("hello"),
+ Assistant(new ThinkingContent("thought", "opaque-signature"), new TextContent("hi"), new ToolCallContent("call-1", "probe", Fixtures.Record("{\"path\":\"a\"}"))),
+ new ToolResultMessage { ToolCallId = "call-1", ToolName = "probe", Content = [new TextContent("result")], IsError = false },
+ ],
+ [Fixtures.Probe]);
+
+ var stdin = ContextSerializer.BuildStdinMessage(context, Fixtures.ProbeCatalog, _images, cacheBreakpoint: false);
+
+ Assert.Equal("user", stdin["type"]!.GetValue());
+ Assert.Equal("user", stdin["message"]!["role"]!.GetValue());
+ var blocks = Blocks(stdin);
+ Assert.Equal(4, blocks.Count);
+ Assert.All(blocks, b => Assert.Equal("text", b.GetProperty("type").GetString()));
+
+ var header = ParseRecord(blocks[0]);
+ Assert.Equal("volcanic-claude-code-context-v1", header.GetProperty("protocol").GetString());
+ Assert.Contains("Continue this conversation", header.GetProperty("instruction").GetString());
+ var map = Assert.Single(header.GetProperty("toolNameMap").EnumerateArray());
+ Assert.Equal("mcp__pi__probe", map.GetProperty("transportName").GetString());
+ Assert.Equal("probe", map.GetProperty("toolName").GetString());
+
+ var user = ParseRecord(blocks[1]);
+ Assert.Equal("user", user.GetProperty("role").GetString());
+ Assert.Equal("hello", user.GetProperty("content").GetString());
+
+ var assistant = ParseRecord(blocks[2]);
+ Assert.Equal("assistant", assistant.GetProperty("role").GetString());
+ var content = assistant.GetProperty("content");
+ Assert.Equal("thinking", content[0].GetProperty("type").GetString());
+ Assert.Equal("thought", content[0].GetProperty("thinking").GetString());
+ Assert.False(content[0].TryGetProperty("signature", out _));
+ Assert.False(content[0].TryGetProperty("redacted", out _));
+ Assert.Equal("hi", content[1].GetProperty("text").GetString());
+ Assert.Equal("toolCall", content[2].GetProperty("type").GetString());
+ Assert.Equal("call-1", content[2].GetProperty("id").GetString());
+ Assert.Equal("mcp__pi__probe", content[2].GetProperty("name").GetString());
+ Assert.Equal("a", content[2].GetProperty("arguments").GetProperty("path").GetString());
+ Assert.False(assistant.TryGetProperty("usage", out _));
+
+ var result = ParseRecord(blocks[3]);
+ Assert.Equal("toolResult", result.GetProperty("role").GetString());
+ Assert.Equal("call-1", result.GetProperty("toolCallId").GetString());
+ Assert.Equal("mcp__pi__probe", result.GetProperty("toolName").GetString());
+ Assert.Equal("result", result.GetProperty("content")[0].GetProperty("text").GetString());
+ Assert.False(result.GetProperty("isError").GetBoolean());
+ }
+
+ [Fact]
+ public void At_signs_are_escaped_so_claude_cannot_expand_paths()
+ {
+ var context = new LlmContext(null, [UserMessage.FromText("Do not expand @/etc/passwd"), Assistant(new TextContent("mail me@example.com"))], []);
+
+ var stdin = ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: false);
+
+ foreach (var block in Blocks(stdin))
+ {
+ var text = block.GetProperty("text").GetString()!;
+ Assert.DoesNotContain("@", text);
+ Assert.Contains(text.Contains("passwd") || text.Contains("example") ? "\\u0040" : "protocol", text);
+ }
+ Assert.Equal("Do not expand @/etc/passwd", ParseRecord(Blocks(stdin)[1]).GetProperty("content").GetString());
+ Assert.Equal("mail me@example.com", ParseRecord(Blocks(stdin)[2]).GetProperty("content")[0].GetProperty("text").GetString());
+ }
+
+ [Fact]
+ public void Cache_control_marks_only_the_last_block_and_only_when_requested()
+ {
+ var context = new LlmContext(null, [UserMessage.FromText("one"), Assistant(new TextContent("two")), UserMessage.FromText("three")], []);
+
+ var marked = Blocks(ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: true));
+ Assert.Equal(4, marked.Count);
+ for (var i = 0; i < 3; i++) Assert.False(marked[i].TryGetProperty("cache_control", out _));
+ var control = marked[3].GetProperty("cache_control");
+ Assert.Equal("ephemeral", control.GetProperty("type").GetString());
+ Assert.Equal("1h", control.GetProperty("ttl").GetString());
+
+ var unmarked = Blocks(ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: false));
+ Assert.All(unmarked, b => Assert.False(b.TryGetProperty("cache_control", out _)));
+ }
+
+ [Fact]
+ public void Images_become_attachments_referenced_after_the_breakpoint()
+ {
+ var image = Pixels("png!");
+ var context = new LlmContext(null,
+ [
+ new UserMessage { Content = [new TextContent("look"), image] },
+ Assistant(new ToolCallContent("c1", "probe", Fixtures.Record("{}"))),
+ new ToolResultMessage { ToolCallId = "c1", ToolName = "probe", Content = [Pixels("tool result"), image], IsError = false },
+ ],
+ [Fixtures.Probe]);
+
+ var stdin = ContextSerializer.BuildStdinMessage(context, Fixtures.ProbeCatalog, _images, cacheBreakpoint: true);
+ var blocks = Blocks(stdin);
+
+ // header + 3 records + the attachment instruction
+ Assert.Equal(5, blocks.Count);
+ var user = ParseRecord(blocks[1]).GetProperty("content");
+ Assert.Equal("image_attachment", user[1].GetProperty("type").GetString());
+ Assert.Matches("^image-[a-f0-9]{64}\\.png$", user[1].GetProperty("attachment").GetString());
+ Assert.Equal("image/png", user[1].GetProperty("mimeType").GetString());
+ Assert.True(blocks[3].TryGetProperty("cache_control", out _), "the breakpoint sits on the last transcript record");
+ Assert.False(blocks[4].TryGetProperty("cache_control", out _), "the attachment list stays outside the breakpoint");
+
+ var instruction = blocks[4].GetProperty("text").GetString()!;
+ Assert.StartsWith("Generated image attachments for image_attachment blocks: @\"", instruction);
+ var transcript = ContextSerializer.Serialize(context, Fixtures.ProbeCatalog, _images);
+ Assert.Equal(2, transcript.AttachmentPaths.Count);
+ Assert.Equal(3, transcript.ImageCount);
+ foreach (var path in transcript.AttachmentPaths)
+ {
+ Assert.True(Path.IsPathRooted(path));
+ Assert.True(File.Exists(path));
+ Assert.Contains($"@\"{path}\"", instruction);
+ }
+ Assert.Equal("png!", File.ReadAllText(transcript.AttachmentPaths[0]));
+ }
+
+ [Fact]
+ public void Tools_outside_the_catalogue_get_the_unavailable_label_and_results_follow_their_call()
+ {
+ var context = new LlmContext(null,
+ [
+ Assistant(new ToolCallContent("c1", "removed tool", Fixtures.Record("{\"x\":1}")), new ToolCallContent("c2", "probe", Fixtures.Record("{}"))),
+ new ToolResultMessage { ToolCallId = "c1", ToolName = "stale name", Content = [new TextContent("one")], IsError = true },
+ new ToolResultMessage { ToolCallId = "orphan", ToolName = "orphaned tool", Content = [new TextContent("two")], IsError = false },
+ ],
+ [Fixtures.Probe]);
+
+ var blocks = Blocks(ContextSerializer.BuildStdinMessage(context, Fixtures.ProbeCatalog, _images, cacheBreakpoint: false));
+ var assistant = ParseRecord(blocks[1]).GetProperty("content");
+ Assert.Equal("[unavailable tool removed tool]", assistant[0].GetProperty("name").GetString());
+ Assert.Equal("mcp__pi__probe", assistant[1].GetProperty("name").GetString());
+ Assert.Equal("[unavailable tool removed tool]", ParseRecord(blocks[2]).GetProperty("toolName").GetString());
+ Assert.True(ParseRecord(blocks[2]).GetProperty("isError").GetBoolean());
+ Assert.Equal("[unavailable tool orphaned tool]", ParseRecord(blocks[3]).GetProperty("toolName").GetString());
+ }
+
+ [Fact]
+ public void Custom_messages_are_user_turns_and_empty_assistant_messages_are_dropped()
+ {
+ var context = new LlmContext(null,
+ [CustomMessage.FromText("note", "steer"), Assistant(), new UserMessage { Content = [new TextContent("a"), new TextContent("b")] }],
+ []);
+
+ var blocks = Blocks(ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: false));
+ Assert.Equal(3, blocks.Count);
+ Assert.Equal("steer", ParseRecord(blocks[1]).GetProperty("content").GetString());
+ Assert.Equal(2, ParseRecord(blocks[2]).GetProperty("content").GetArrayLength());
+ }
+
+ [Fact]
+ public void Transcript_over_32_megabytes_is_a_context_overflow()
+ {
+ var context = new LlmContext(null, [UserMessage.FromText(new string('x', ContextSerializer.MaxTranscriptBytes + 1024))], []);
+
+ var ex = Assert.Throws(() => ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: false));
+ Assert.Equal("context_overflow", ex.Code);
+ Assert.StartsWith("context_length_exceeded: ", ex.Message);
+ Assert.StartsWith("context_length_exceeded: ", ex.FormatForUser());
+ }
+
+ [Fact]
+ public void Invalid_images_and_misplaced_blocks_are_rejected()
+ {
+ var badBase64 = new LlmContext(null, [new UserMessage { Content = [new ImageContent("%%%", "image/png")] }], []);
+ Assert.Equal("image", Assert.Throws(() => ContextSerializer.Serialize(badBase64, Fixtures.EmptyCatalog, _images)).Code);
+
+ var badType = new LlmContext(null, [new UserMessage { Content = [new ImageContent("AA==", "image/bmp")] }], []);
+ Assert.Equal("image", Assert.Throws(() => ContextSerializer.Serialize(badType, Fixtures.EmptyCatalog, _images)).Code);
+
+ var thinkingInUser = new LlmContext(null, [new UserMessage { Content = [new ThinkingContent("leaked")] }], []);
+ Assert.Equal("content_shape", Assert.Throws(() => ContextSerializer.Serialize(thinkingInUser, Fixtures.EmptyCatalog, _images)).Code);
+
+ var tooMany = new LlmContext(null, [new UserMessage { Content = Enumerable.Range(0, 21).Select(i => (ContentBlock)Pixels($"img {i}")).ToList() }], []);
+ Assert.Contains("At most 20 images", Assert.Throws(() => ContextSerializer.Serialize(tooMany, Fixtures.EmptyCatalog, _images)).Message);
+ }
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/DoctorCommandTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/DoctorCommandTests.cs
new file mode 100644
index 0000000..08fd813
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/DoctorCommandTests.cs
@@ -0,0 +1,62 @@
+using Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
+
+public class DoctorCommandTests
+{
+ private static string BridgeDll => Path.Combine(AppContext.BaseDirectory, BridgeLocator.BridgeFileName);
+
+ private static string? Dotnet => BridgeLocator.FindDotnet(Environment.GetEnvironmentVariable);
+
+ [Fact]
+ public void Bridge_locator_finds_the_bridge_next_to_the_host_and_a_dotnet_muxer()
+ {
+ Assert.Equal(BridgeDll, BridgeLocator.FindBridgeDll());
+ Assert.True(File.Exists(Path.ChangeExtension(BridgeDll, ".runtimeconfig.json")), "the bridge runtimeconfig must travel with the dll");
+ var dotnet = Dotnet;
+ Assert.NotNull(dotnet);
+ Assert.True(File.Exists(dotnet));
+ // Without PATH or DOTNET_ROOT the only source is the current process, which must then be the muxer itself.
+ if (BridgeLocator.FindDotnet(_ => null) is { } fromProcess)
+ Assert.Equal("dotnet", Path.GetFileNameWithoutExtension(fromProcess), ignoreCase: true);
+ }
+
+ [Fact]
+ public async Task Bridge_probe_completes_a_real_handshake_against_the_built_bridge()
+ {
+ var probe = await DoctorCommand.ProbeBridgeAsync(Dotnet, BridgeDll, TimeSpan.FromSeconds(30), CancellationToken.None);
+
+ Assert.True(probe.Ok, probe.Detail);
+ Assert.Equal("handshake completed: 1 tool listed, ready marker written", probe.Detail);
+ Assert.Equal([Dotnet!, BridgeDll], probe.Argv);
+ }
+
+ [Fact]
+ public async Task Bridge_probe_reports_missing_pieces()
+ {
+ var noDotnet = await DoctorCommand.ProbeBridgeAsync(null, BridgeDll, TimeSpan.FromSeconds(5), CancellationToken.None);
+ Assert.False(noDotnet.Ok);
+ Assert.Contains("dotnet", noDotnet.Detail);
+
+ var noBridge = await DoctorCommand.ProbeBridgeAsync(Dotnet, Path.Combine(AppContext.BaseDirectory, "missing.dll"), TimeSpan.FromSeconds(5), CancellationToken.None);
+ Assert.False(noBridge.Ok);
+ Assert.Contains("not found", noBridge.Detail);
+
+ var notABridge = await DoctorCommand.ProbeBridgeAsync(Dotnet, Path.Combine(AppContext.BaseDirectory, "Volcanic.AgenticCore.Extensions.ClaudeCode.dll"), TimeSpan.FromSeconds(30), CancellationToken.None);
+ Assert.False(notABridge.Ok);
+ }
+
+ [Fact]
+ public async Task Report_lists_the_cli_status_the_bridge_and_the_models()
+ {
+ var registry = new SessionRegistry();
+ var (report, ok) = await DoctorCommand.BuildReportAsync(new ClaudeCodeApiOptions { ClaudePath = null, BridgeDllPath = BridgeDll, DotnetPath = Dotnet, GetEnv = _ => null, ParentEnvironment = new Dictionary() }, registry, CancellationToken.None);
+
+ Assert.False(ok);
+ Assert.Contains("# Claude Code doctor", report);
+ Assert.Contains("Claude Code: **not found**", report);
+ Assert.Contains("Bridge: ok", report);
+ Assert.Contains("Models: sonnet, opus, haiku, fable", report);
+ Assert.Contains("Registered sessions: 0", report);
+ }
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures.cs
new file mode 100644
index 0000000..97be752
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures.cs
@@ -0,0 +1,42 @@
+using System.Text.Json;
+using Volcanic.AgenticCore.Abstractions.Providers;
+using Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
+
+/// Captured Claude Code 2.1.281 output (see Fixtures/README.md) and the catalogue those captures were made with.
+internal static class Fixtures
+{
+ /// The captures were taken with the original transport prefix mcp__pi__.
+ public const string ServerName = "pi";
+
+ public static string Directory => Path.Combine(AppContext.BaseDirectory, "ClaudeCode", "Fixtures");
+
+ public static string FixturePath(string name) => Path.Combine(Directory, name);
+
+ public static string HelpText => File.ReadAllText(FixturePath("claude-2.1.281-help.txt"));
+
+ public static IReadOnlyList StreamLines(string scenario) =>
+ File.ReadAllLines(FixturePath($"claude-2.1.281-stream-{scenario}.jsonl")).Where(line => line.Trim().Length > 0).ToList();
+
+ public static IReadOnlyList StreamRecords(string scenario) => StreamLines(scenario).Select(Record).ToList();
+
+ public static JsonElement Record(string json) => JsonSerializer.Deserialize(json);
+
+ public static ToolDescriptor Probe => new("probe", "probe", Record("{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}"));
+
+ public static ToolCatalog ProbeCatalog => new([Probe], ServerName);
+
+ public static ToolCatalog EmptyCatalog => new([], ServerName);
+
+ public static Model Sonnet => ClaudeCodeModels.Sonnet;
+
+ /// An init record in the fixtures' shape with the given tools and MCP servers.
+ public static JsonElement Init(string tools = "[]", string mcpServers = "[]", string apiKeySource = "none", string permissionMode = "dontAsk") => Record(
+ $$"""{"type":"system","subtype":"init","cwd":"/project","session_id":"s","tools":{{tools}},"mcp_servers":{{mcpServers}},"model":"claude-sonnet-5","permissionMode":"{{permissionMode}}","slash_commands":[],"apiKeySource":"{{apiKeySource}}","skills":[],"plugins":[]}""");
+
+ public static JsonElement ProbeInit(string apiKeySource = "none", string mcpServers = "[{\"name\":\"pi\",\"status\":\"connected\"}]") =>
+ Init("[\"mcp__pi__probe\"]", mcpServers, apiKeySource);
+
+ public static bool IsPosix => OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD();
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/JsonlReaderTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/JsonlReaderTests.cs
new file mode 100644
index 0000000..a55f7c0
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/JsonlReaderTests.cs
@@ -0,0 +1,83 @@
+using System.Text;
+using System.Text.Json;
+using Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
+
+public class JsonlReaderTests
+{
+ /// Delivers at most bytes per read so framing across chunk boundaries is exercised.
+ private sealed class TrickleStream(byte[] data, int chunk) : Stream
+ {
+ private int _position;
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => data.Length;
+ public override long Position { get => _position; set => throw new NotSupportedException(); }
+ public override void Flush() { }
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ var n = Math.Min(chunk, Math.Min(count, data.Length - _position));
+ Array.Copy(data, _position, buffer, offset, n);
+ _position += n;
+ return n;
+ }
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+ }
+
+ private static async Task> ReadAll(string text, int chunk = 3, int max = JsonlReader.DefaultMaxRecordBytes)
+ {
+ var records = new List();
+ await foreach (var record in JsonlReader.ReadAsync(new TrickleStream(Encoding.UTF8.GetBytes(text), chunk), max))
+ records.Add(record);
+ return records;
+ }
+
+ [Fact]
+ public async Task Splits_on_lf_strips_one_cr_and_accepts_an_unterminated_last_line()
+ {
+ var records = await ReadAll("{\"text\":\"snowman ☃\"}\r\n{\"n\":2}\n\n \n{\"c\":3}");
+
+ Assert.Equal(3, records.Count);
+ Assert.Equal("snowman ☃", records[0].GetProperty("text").GetString());
+ Assert.Equal(2, records[1].GetProperty("n").GetInt32());
+ Assert.Equal(3, records[2].GetProperty("c").GetInt32());
+ }
+
+ [Fact]
+ public async Task Does_not_split_on_unicode_line_separators_inside_strings()
+ {
+ // Raw U+2028 / U+2029 bytes inside a JSON string are ordinary content, not record boundaries.
+ var lineSeparator = char.ConvertFromUtf32(0x2028);
+ var paragraphSeparator = char.ConvertFromUtf32(0x2029);
+ var text = "a" + lineSeparator + "b" + paragraphSeparator + "c";
+
+ var records = await ReadAll("{\"text\":\"" + text + "\"}\n", chunk: 1);
+
+ Assert.Equal(text, Assert.Single(records).GetProperty("text").GetString());
+ }
+
+ [Fact]
+ public async Task Malformed_lines_fail_with_protocol_stream()
+ {
+ var ex = await Assert.ThrowsAsync(() => ReadAll("nope\n"));
+ Assert.Equal("protocol_stream", ex.Code);
+ Assert.Contains("malformed", ex.Message);
+ }
+
+ [Fact]
+ public async Task Oversized_records_are_rejected_before_and_at_the_newline()
+ {
+ var unterminated = await Assert.ThrowsAsync(() => ReadAll("123456", chunk: 2, max: 4));
+ Assert.Contains("oversized", unterminated.Message);
+
+ var terminated = await Assert.ThrowsAsync(() => ReadAll("12345\n", chunk: 6, max: 4));
+ Assert.Contains("oversized", terminated.Message);
+
+ var exact = await ReadAll("1234\r\n", chunk: 6, max: 4);
+ Assert.Equal(1234, Assert.Single(exact).GetInt32());
+ }
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ProviderExtensionTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ProviderExtensionTests.cs
new file mode 100644
index 0000000..c396045
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ProviderExtensionTests.cs
@@ -0,0 +1,193 @@
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+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.Sessions;
+using Volcanic.AgenticCore.Abstractions.Tools;
+using Volcanic.AgenticCore.Abstractions.Ui;
+using Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
+
+public class ProviderExtensionTests
+{
+ /// Records registrations; every action throws so a misuse during activation is loud.
+ private sealed class FakeExtensionApi : IExtensionApi
+ {
+ public List Providers { get; } = [];
+ public Dictionary Commands { get; } = new();
+ public Dictionary Handlers { get; } = new();
+ public List<(CustomMessage Message, MessageDelivery Delivery, bool TriggerTurn)> Sent { get; } = [];
+
+ public string ExtensionName => "claude-code-provider";
+ public ILogger Logger => NullLogger.Instance;
+ public IEventBus Events => throw new NotSupportedException();
+
+ public IDisposable On(ExtensionHandler handler) where TEvent : ExtensionEvent
+ {
+ Handlers[typeof(TEvent)] = handler;
+ return new Subscription();
+ }
+
+ public void RegisterTool(ITool tool) => throw new NotSupportedException();
+ public void RegisterCommand(string name, CommandDefinition command) => Commands[name] = command;
+ public void RegisterProvider(ProviderRegistration provider) => Providers.Add(provider);
+ public void UnregisterProvider(string providerId) => throw new NotSupportedException();
+ public void SendMessage(CustomMessage message, MessageDelivery delivery = MessageDelivery.Steer, bool triggerTurn = true) => Sent.Add((message, delivery, triggerTurn));
+ public void SendUserMessage(string text, IReadOnlyList? images = null, MessageDelivery delivery = MessageDelivery.Steer) => throw new NotSupportedException();
+ public void AppendEntry(string customType, JsonElement? data = null) => throw new NotSupportedException();
+ public Task SetModelAsync(Model model, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public ThinkingLevel GetThinkingLevel() => throw new NotSupportedException();
+ public void SetThinkingLevel(ThinkingLevel level) => throw new NotSupportedException();
+ public IReadOnlyList GetActiveTools() => throw new NotSupportedException();
+ public void SetActiveTools(IEnumerable names) => throw new NotSupportedException();
+ public IReadOnlyList GetAllTools() => throw new NotSupportedException();
+ public IReadOnlyList GetCommands() => throw new NotSupportedException();
+ public string? GetSessionName() => throw new NotSupportedException();
+ public void SetSessionName(string? name) => throw new NotSupportedException();
+ public Task ExecAsync(string command, IReadOnlyList args, ExecOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+
+ public ValueTask Raise(TEvent e, IExtensionContext ctx) where TEvent : ExtensionEvent =>
+ ((ExtensionHandler)Handlers[typeof(TEvent)])(e, ctx, CancellationToken.None);
+
+ private sealed class Subscription : IDisposable { public void Dispose() { } }
+ }
+
+ private sealed class FakeContext(string cwd, string sessionId, IUserInterface? ui = null) : IExtensionCommandContext
+ {
+ public string Cwd => cwd;
+ public string SessionId => sessionId;
+ public bool HasUi => ui is not null;
+ public IUserInterface Ui => ui ?? NullUserInterface.Instance;
+ public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
+ public IModelRegistry ModelRegistry => throw new NotSupportedException();
+ public Model? Model => null;
+ public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
+ public CancellationToken Signal => CancellationToken.None;
+ public ILogger Logger => NullLogger.Instance;
+ public bool IsIdle() => true;
+ public void Abort() { }
+ public bool HasPendingMessages() => false;
+ public ContextUsage? GetContextUsage() => null;
+ public string GetSystemPrompt() => "";
+ public void Compact(CompactOptions? options = null) { }
+ public Task WaitForIdleAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public Task NewSessionAsync(NewSessionOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
+ public Task SwitchSessionAsync(string sessionId, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
+ }
+
+ private sealed class RecordingUi : IUserInterface
+ {
+ public List<(string Message, NotifyKind Kind)> Notices { get; } = [];
+ public Task SelectAsync(string title, IReadOnlyList options, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(null);
+ public Task ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(false);
+ public Task InputAsync(string title, string? placeholder, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(null);
+ public void Notify(string message, NotifyKind kind = NotifyKind.Info) => Notices.Add((message, kind));
+ public void SetStatus(string key, string? text) { }
+ }
+
+ [Fact]
+ public async Task Activation_registers_the_provider_the_doctor_command_and_session_hooks()
+ {
+ var api = new FakeExtensionApi();
+ var registry = new SessionRegistry();
+ var extension = new ClaudeCodeProviderExtension(new ClaudeCodeProviderOptions { ClaudePath = "/nonexistent/claude", Registry = registry, GetEnv = _ => null });
+
+ Assert.Equal("claude-code-provider", extension.Name);
+ await extension.ActivateAsync(api, CancellationToken.None);
+
+ var provider = Assert.Single(api.Providers);
+ Assert.Equal("claude-code", provider.Id);
+ Assert.Equal("Claude Code (subscription)", provider.Name);
+ Assert.Equal("claude-code-headless", provider.Api);
+ Assert.Equal("claude-code://local", provider.BaseUrl);
+ Assert.False(provider.RequiresApiKey);
+ Assert.Equal(["sonnet", "opus", "haiku", "fable"], provider.Models.Select(m => m.Id).ToArray());
+ Assert.All(provider.Models, m => Assert.Equal(ModelCost.Zero, m.Cost));
+ Assert.Equal(1_000_000, provider.Models[0].ContextWindow);
+ Assert.Equal(64_000, provider.Models[0].MaxTokens);
+ Assert.True(provider.Models[0].Reasoning);
+ Assert.False(provider.Models[2].Reasoning);
+ Assert.Equal("xhigh", provider.Models[0].ThinkingLevelMap![ThinkingLevel.XHigh]);
+ Assert.Null(provider.Models[0].ThinkingLevelMap![ThinkingLevel.Off]);
+ Assert.Contains(ModelInput.Image, provider.Models[1].Input);
+
+ var command = Assert.Single(api.Commands);
+ Assert.Equal("claude-code-doctor", command.Key);
+ Assert.NotNull(command.Value.Description);
+ Assert.Equal(3, api.Handlers.Count);
+ Assert.NotNull(extension.Api);
+ }
+
+ [Fact]
+ public async Task Session_hooks_register_and_remove_sessions_and_message_end_normalises_overflow()
+ {
+ var api = new FakeExtensionApi();
+ var registry = new SessionRegistry();
+ var ui = new RecordingUi();
+ var cwd = Directory.CreateTempSubdirectory("ext-test-").FullName;
+ try
+ {
+ await new ClaudeCodeProviderExtension(new ClaudeCodeProviderOptions { ClaudePath = null, Registry = registry, GetEnv = _ => null }).ActivateAsync(api, CancellationToken.None);
+ var ctx = new FakeContext(cwd, "session-1", ui);
+
+ await api.Raise(new SessionStartEvent(SessionStartReason.Startup), ctx);
+ var entry = registry.Get("session-1");
+ Assert.NotNull(entry);
+ Assert.Equal(cwd, entry.Cwd);
+ Assert.NotNull(entry.Notify);
+ Assert.Contains(ui.Notices, n => n.Message.Contains("not found") && n.Kind == NotifyKind.Warning);
+
+ // A second start for a new session id drops the earlier registration.
+ await api.Raise(new SessionStartEvent(SessionStartReason.New, "session-1"), new FakeContext(cwd, "session-2"));
+ Assert.Null(registry.Get("session-1"));
+ Assert.Null(registry.Get("session-2")!.Notify);
+
+ await api.Raise(new SessionShutdownEvent(SessionShutdownReason.Quit), new FakeContext(cwd, "session-2"));
+ Assert.Equal(0, registry.Count);
+
+ var overflow = new MessageEndEvent(new AssistantMessage { Provider = "claude-code", Api = "claude-code-headless", Model = "sonnet", StopReason = StopReason.Error, ErrorMessage = "[protocol_stream] Claude Code request failed (400): Prompt is too long" });
+ await api.Raise(overflow, ctx);
+ Assert.Equal("context_length_exceeded: [protocol_stream] Claude Code request failed (400): Prompt is too long", Assert.IsType(overflow.Message).ErrorMessage);
+
+ var already = new AssistantMessage { Provider = "claude-code", Api = "x", Model = "m", StopReason = StopReason.Error, ErrorMessage = "context_length_exceeded: too long" };
+ var unchanged = new MessageEndEvent(already);
+ await api.Raise(unchanged, ctx);
+ Assert.Same(already, unchanged.Message);
+
+ var other = new UserMessage { Content = [new TextContent("prompt is too long")] };
+ var user = new MessageEndEvent(other);
+ await api.Raise(user, ctx);
+ Assert.Same(other, user.Message);
+ }
+ finally
+ {
+ Directory.Delete(cwd, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task Doctor_command_sends_a_report_without_triggering_a_turn()
+ {
+ var api = new FakeExtensionApi();
+ var ui = new RecordingUi();
+ await new ClaudeCodeProviderExtension(new ClaudeCodeProviderOptions { ClaudePath = null, BridgeDllPath = null, DotnetPath = null, Registry = new SessionRegistry(), GetEnv = _ => null }).ActivateAsync(api, CancellationToken.None);
+
+ await api.Commands["claude-code-doctor"].Handler("", new FakeContext(Path.GetTempPath(), "s", ui), CancellationToken.None);
+
+ var (message, delivery, triggerTurn) = Assert.Single(api.Sent);
+ Assert.Equal("claude-code-doctor", message.CustomType);
+ Assert.Equal(MessageDelivery.Steer, delivery);
+ Assert.False(triggerTurn);
+ Assert.StartsWith("# Claude Code doctor", message.Text);
+ Assert.Contains("Claude Code: **not found**", message.Text);
+ // The bridge and dotnet are auto-located next to the test host, so the probe itself is reported (ok or broken).
+ Assert.Contains("- Bridge: ", message.Text);
+ Assert.Contains("Problems found", message.Text);
+ var notice = Assert.Single(ui.Notices);
+ Assert.Equal(NotifyKind.Warning, notice.Kind);
+ }
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/SessionRegistryTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/SessionRegistryTests.cs
new file mode 100644
index 0000000..3897ac6
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/SessionRegistryTests.cs
@@ -0,0 +1,151 @@
+using Volcanic.AgenticCore.Abstractions.Providers;
+using Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
+
+public class SessionRegistryTests : IDisposable
+{
+ private readonly string _root = Directory.CreateTempSubdirectory("registry-test-").FullName;
+ private readonly SessionRegistry _registry = new();
+
+ public void Dispose() => Directory.Delete(_root, recursive: true);
+
+ private string Dir(string name)
+ {
+ var path = Path.Combine(_root, name);
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ private static LlmContext Context(string? systemPrompt) => new(systemPrompt, [], []);
+
+ private static string CoreStyle(string cwd) => $"You are an assistant.\n\n\nCurrent date: 2026-09-25\nWorking directory: {cwd}\n";
+
+ private static string CwdTag(string cwd) => $"\nhi\n\n\n\n{cwd}\n\n\n\ncontext\n";
+
+ [Fact]
+ public void Registered_session_wins_and_reports_source_session()
+ {
+ var a = Dir("a");
+ var b = Dir("b");
+ _registry.Register("s-a", a);
+ _registry.Register("s-b", b);
+
+ var resolved = _registry.Resolve("s-a", Context(CwdTag(a)), hasTools: true);
+
+ Assert.Equal(a, resolved.Cwd);
+ Assert.Equal("session", resolved.Source);
+ Assert.Same(_registry.Get("s-a")!.Images, resolved.Images);
+ Assert.Equal(2, _registry.Count);
+ }
+
+ [Fact]
+ public void Prompt_cwd_tag_resolves_an_unknown_session_and_borrows_a_matching_store()
+ {
+ var parent = Dir("parent");
+ var child = Dir("child");
+ _registry.Register("s-parent", parent);
+ _registry.Register("s-child-dir", child);
+
+ var resolved = _registry.Resolve("unknown", Context(CwdTag(child)), hasTools: true);
+
+ Assert.Equal(child, resolved.Cwd);
+ Assert.Equal("prompt", resolved.Source);
+ Assert.Same(_registry.Get("s-child-dir")!.Images, resolved.Images);
+ }
+
+ [Fact]
+ public void Trailing_working_directory_line_in_the_core_prompt_is_recognised()
+ {
+ var project = Dir("project");
+ var resolved = _registry.Resolve(null, Context(CoreStyle(project)), hasTools: true);
+
+ Assert.Equal(project, resolved.Cwd);
+ Assert.Equal("prompt", resolved.Source);
+ Assert.NotNull(resolved.Images);
+ Assert.Equal(project, SessionRegistry.PromptWorkingDirectory($"intro\nCurrent working directory: {project}"));
+ Assert.Equal(project, SessionRegistry.PromptWorkingDirectory($"intro\nWorking directory: {project}\n"));
+ }
+
+ [Fact]
+ public void Tool_free_requests_borrow_the_newest_session()
+ {
+ var older = Dir("older");
+ var newer = Dir("newer");
+ _registry.Register("s-1", older);
+ _registry.Register("s-2", newer);
+
+ var resolved = _registry.Resolve("fresh-compaction-id", Context("Summarise the conversation."), hasTools: false);
+
+ Assert.Equal(newer, resolved.Cwd);
+ Assert.Equal("oneshot", resolved.Source);
+ Assert.Same(_registry.Get("s-2")!.Images, resolved.Images);
+
+ // Re-registering an older session makes it the newest.
+ _registry.Register("s-1", older);
+ Assert.Equal(older, _registry.Resolve(null, Context(null), hasTools: false).Cwd);
+ }
+
+ [Fact]
+ public void Tool_bearing_requests_without_a_recognised_directory_are_refused()
+ {
+ _registry.Register("s-1", Dir("one"));
+
+ var ex = Assert.Throws(() => _registry.Resolve("fresh", Context("no directory here"), hasTools: true));
+ Assert.Equal("working_directory", ex.Code);
+ Assert.Contains("1 session(s) are live", ex.Message);
+
+ var empty = new SessionRegistry();
+ Assert.Equal("working_directory", Assert.Throws(() => empty.Resolve(null, Context(null), hasTools: false)).Code);
+ }
+
+ [Fact]
+ public void Registered_session_whose_prompt_names_another_directory_fails_closed()
+ {
+ var a = Dir("a");
+ var b = Dir("b");
+ _registry.Register("s-a", a);
+
+ var ex = Assert.Throws(() => _registry.Resolve("s-a", Context(CwdTag(b)), hasTools: true));
+ Assert.Equal("working_directory", ex.Code);
+ Assert.Contains("another session's directory", ex.Message);
+
+ // Separator and trailing-slash differences are not a conflict.
+ Assert.Equal(a, _registry.Resolve("s-a", Context(CwdTag(a + Path.DirectorySeparatorChar)), hasTools: true).Cwd);
+ }
+
+ [Fact]
+ public void Missing_or_relative_directories_are_refused()
+ {
+ _registry.Register("s-gone", Path.Combine(_root, "gone"));
+ Assert.Equal("working_directory", Assert.Throws(() => _registry.Resolve("s-gone", Context(null), hasTools: true)).Code);
+
+ Assert.Equal("working_directory", Assert.Throws(() => _registry.Resolve(null, Context(CwdTag("relative/dir")), hasTools: true)).Code);
+ }
+
+ [Fact]
+ public void Repository_authored_cwd_sections_are_ignored_and_the_last_statement_wins()
+ {
+ var injected = "\n\n\n/srv/attacker\n\n\n\n\n" + CwdTag("/srv/a/project");
+ Assert.Equal("/srv/a/project", SessionRegistry.PromptWorkingDirectory(injected));
+
+ var trailing = CwdTag("/srv/a/project") + "\n\n\n\n/srv/attacker\n\n";
+ Assert.Equal("/srv/a/project", SessionRegistry.PromptWorkingDirectory(trailing));
+
+ Assert.Null(SessionRegistry.PromptWorkingDirectory("\n\n/srv/attacker\n\n"));
+ Assert.Equal("/srv/late", SessionRegistry.PromptWorkingDirectory(CwdTag("/srv/early") + "\nWorking directory: /srv/late"));
+ Assert.Equal("/srv/late", SessionRegistry.PromptWorkingDirectory("Working directory: /srv/early\n\n" + CwdTag("/srv/late")));
+ Assert.Null(SessionRegistry.PromptWorkingDirectory("You are an assistant."));
+ Assert.Null(SessionRegistry.PromptWorkingDirectory(null));
+ }
+
+ [Fact]
+ public void Remove_disposes_the_image_store()
+ {
+ var entry = _registry.Register("s", Dir("s"));
+ Assert.True(_registry.Remove("s"));
+ Assert.False(_registry.Remove("s"));
+ Assert.Null(_registry.Get("s"));
+ Assert.Throws(() => entry.Images.Add(new Abstractions.Messages.ImageContent("AA==", "image/png")));
+ }
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/StreamEventMapperTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/StreamEventMapperTests.cs
new file mode 100644
index 0000000..d12d628
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/StreamEventMapperTests.cs
@@ -0,0 +1,356 @@
+using System.Text.Json;
+using Volcanic.AgenticCore.Abstractions.Messages;
+using Volcanic.AgenticCore.Abstractions.Providers;
+using Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
+
+public class StreamEventMapperTests
+{
+ private sealed record Replay(StreamEventMapper Mapper, List Events, ClaudeCodeException? Rejected, AssistantMessageEvent? Terminal);
+
+ /// Feeds every captured record the way the provider does: later stream events are ignored after a latch, and the terminal is produced after the result.
+ private static Replay ReplayCapture(string scenario, ToolCatalog? catalog = null)
+ {
+ var mapper = new StreamEventMapper(Fixtures.Sonnet, catalog ?? Fixtures.ProbeCatalog);
+ var events = new List();
+ ClaudeCodeException? rejected = null;
+ foreach (var record in Fixtures.StreamRecords(scenario))
+ {
+ try
+ {
+ events.AddRange(mapper.Accept(record));
+ }
+ catch (ClaudeCodeException ex)
+ {
+ rejected = ex;
+ break;
+ }
+ }
+ AssistantMessageEvent? terminal = null;
+ if (rejected is null && (mapper.HandoffLatched || mapper.HasSuccessfulResult))
+ {
+ terminal = mapper.Complete();
+ events.Add(terminal);
+ }
+ return new Replay(mapper, events, rejected, terminal);
+ }
+
+ private static string[] Kinds(IEnumerable events) => events.Select(e => e.GetType().Name).ToArray();
+
+ [Fact]
+ public void Tool_ok_capture_maps_text_then_the_proposal_and_ignores_everything_after_the_latch()
+ {
+ var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
+ var records = Fixtures.StreamRecords("tool-ok");
+ var events = new List();
+ var latchedAt = -1;
+ for (var i = 0; i < records.Count; i++)
+ {
+ events.AddRange(mapper.Accept(records[i]));
+ if (mapper.HandoffLatched && latchedAt < 0) latchedAt = i;
+ }
+
+ Assert.Equal(
+ ["Start", "TextStart", "TextDelta", "TextEnd", "ToolCallStart", "ToolCallDelta", "ToolCallEnd"],
+ Kinds(events));
+ Assert.Equal("calling", Assert.IsType(events[2]).Delta);
+ Assert.Equal("probe", Assert.IsType(events[4]).ToolName);
+ Assert.Equal("toolu_ok", Assert.IsType(events[4]).Id);
+ var end = Assert.IsType(events[6]);
+ Assert.Equal("probe", end.ToolCall.Name);
+ Assert.Equal("probe", end.ToolCall.Arguments.GetProperty("path").GetString());
+
+ // The latch happens at the tool_use message_delta; the second turn Claude Code starts afterwards produces nothing.
+ Assert.True(latchedAt > 0);
+ Assert.Contains("\"stop_reason\":\"tool_use\"", Fixtures.StreamLines("tool-ok")[latchedAt]);
+ Assert.Equal(StopReason.ToolUse, mapper.PendingStop);
+ Assert.True(mapper.SawResult);
+ Assert.Equal("claude-sonnet-5", mapper.ResponseModel);
+
+ var done = Assert.IsType(mapper.Complete());
+ Assert.Equal(StopReason.ToolUse, done.Reason);
+ Assert.Equal(StopReason.ToolUse, done.Message.StopReason);
+ Assert.Equal(20, done.Message.Usage.Input);
+ Assert.Equal(4, done.Message.Usage.Output);
+ Assert.Equal("calling", done.Message.Text);
+ Assert.Single(done.Message.ToolCalls);
+ Assert.Equal("msg_1", done.Message.ResponseId);
+ }
+
+ [Fact]
+ public void Max_tokens_capture_is_a_length_stop_that_keeps_the_text()
+ {
+ var replay = ReplayCapture("max-tokens");
+
+ Assert.Null(replay.Rejected);
+ var done = Assert.IsType(replay.Terminal);
+ Assert.Equal(StopReason.Length, done.Reason);
+ Assert.Equal("truncated by limit", done.Message.Text);
+ Assert.Single(replay.Events.OfType());
+ Assert.Single(replay.Events.OfType());
+ }
+
+ [Fact]
+ public void Context_window_exceeded_capture_is_a_recoverable_length_stop()
+ {
+ // The API stops generation at the window with model_context_window_exceeded; Claude Code answers it like max_tokens.
+ var replay = ReplayCapture("context-window-exceeded");
+
+ Assert.Null(replay.Rejected);
+ var done = Assert.IsType(replay.Terminal);
+ Assert.Equal(StopReason.Length, done.Reason);
+ Assert.Equal("cut off by the window", done.Message.Text);
+ }
+
+ [Fact]
+ public void Prompt_too_long_capture_is_a_context_overflow()
+ {
+ var replay = ReplayCapture("prompt-too-long");
+
+ Assert.NotNull(replay.Rejected);
+ var ex = replay.Rejected;
+ Assert.Equal("context_overflow", ex.Code);
+ Assert.StartsWith("context_length_exceeded: ", ex.Message);
+ Assert.Contains("Prompt is too long", ex.Message);
+ Assert.Contains("(400)", ex.Message);
+ Assert.StartsWith("context_length_exceeded:", ex.FormatForUser());
+ Assert.False(ex.Retryable);
+ }
+
+ [Theory]
+ [InlineData("drop", "began retrying")]
+ [InlineData("drop-tool", "mid-response API error")]
+ [InlineData("drop-text-stop", "continue a cut-off response")]
+ [InlineData("sse-error", "non-streaming request")]
+ [InlineData("drop-partial-tool", "began retrying")]
+ public void Mid_response_recoveries_fail_retryably(string scenario, string cause)
+ {
+ var replay = ReplayCapture(scenario);
+
+ Assert.NotNull(replay.Rejected);
+ var ex = replay.Rejected;
+ Assert.Equal("stream_interrupted", ex.Code);
+ Assert.True(ex.Retryable);
+ Assert.Contains("stream ended before message_stop", ex.Message);
+ Assert.Contains(cause, ex.Message);
+ Assert.Contains("stream ended before message_stop", ex.FormatForUser());
+ // Nothing already streamed is rewritten: no content index starts twice and Start is published once.
+ var starts = replay.Events.Where(e => e is AssistantMessageEvent.TextStart or AssistantMessageEvent.ThinkingStart or AssistantMessageEvent.ToolCallStart)
+ .Select(e => e switch
+ {
+ AssistantMessageEvent.TextStart t => t.ContentIndex,
+ AssistantMessageEvent.ThinkingStart t => t.ContentIndex,
+ AssistantMessageEvent.ToolCallStart t => t.ContentIndex,
+ _ => -1,
+ }).ToList();
+ Assert.Equal(starts.Distinct().ToList(), starts);
+ Assert.Single(replay.Events.OfType());
+ Assert.DoesNotContain(replay.Events, e => e.IsTerminal);
+ }
+
+ [Fact]
+ public void Partial_tool_arguments_hold_a_deferred_failure_until_a_recovery_signal_claims_it()
+ {
+ var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
+ var records = Fixtures.StreamRecords("drop-partial-tool");
+ // init, status, message_start, block start, truncated delta, block stop, message_stop
+ var events = records.Take(7).SelectMany(mapper.Accept).ToList();
+
+ Assert.Equal(["Start", "ToolCallStart", "ToolCallDelta"], Kinds(events));
+ Assert.Contains("invalid arguments for tool probe", mapper.DeferredError);
+ var ex = Assert.Throws(() => mapper.Accept(records[7]));
+ Assert.Equal("stream_interrupted", ex.Code);
+ }
+
+ [Fact]
+ public void Pre_stream_retry_and_post_stop_drop_do_not_fail_ordinary_turns()
+ {
+ var before = ReplayCapture("http529");
+ Assert.Null(before.Rejected);
+ Assert.Equal(StopReason.Stop, Assert.IsType(before.Terminal).Reason);
+ Assert.Equal("answer after a pre-stream retry", before.Mapper.Builder.Current.Text);
+
+ var after = ReplayCapture("post-stop");
+ Assert.Null(after.Rejected);
+ Assert.Equal(StopReason.Stop, Assert.IsType(after.Terminal).Reason);
+ Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "Done"], Kinds(after.Events));
+ Assert.Equal(10, after.Mapper.Usage.Input);
+ Assert.Equal(2, after.Mapper.Usage.Output);
+ }
+
+ [Fact]
+ public void Init_validation_rejects_api_key_auth_tool_mismatch_and_disconnected_mcp()
+ {
+ var auth = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog).Accept(Fixtures.ProbeInit(apiKeySource: "user")));
+ Assert.Equal("auth", auth.Code);
+ Assert.Contains("subscription auth", auth.Message);
+
+ var mismatch = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog).Accept(Fixtures.ProbeInit()));
+ Assert.Equal("protocol_init", mismatch.Code);
+ Assert.Contains("unexpected tool set", mismatch.Message);
+
+ var builtIn = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog).Accept(Fixtures.Init("[\"mcp__pi__probe\",\"Bash\"]", "[{\"name\":\"pi\",\"status\":\"connected\"}]")));
+ Assert.Equal("protocol_init", builtIn.Code);
+
+ var failed = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog).Accept(Fixtures.ProbeInit(mcpServers: "[{\"name\":\"pi\",\"status\":\"failed\"}]")));
+ Assert.Equal("mcp_startup", failed.Code);
+
+ var wrongName = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, new ToolCatalog([Fixtures.Probe], "volcanic")).Accept(Fixtures.Init("[\"mcp__volcanic__probe\"]", "[{\"name\":\"pi\",\"status\":\"connected\"}]")));
+ Assert.Equal("mcp_startup", wrongName.Code);
+
+ var unexpectedServer = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog).Accept(Fixtures.Init("[]", "[{\"name\":\"other\",\"status\":\"connected\"}]")));
+ Assert.Equal("mcp_startup", unexpectedServer.Code);
+
+ var permission = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog).Accept(Fixtures.Init(permissionMode: "default")));
+ Assert.Equal("protocol_init", permission.Code);
+
+ var ok = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
+ Assert.Equal(["Start"], Kinds(ok.Accept(Fixtures.ProbeInit())));
+ Assert.True(ok.SawInit);
+ Assert.Equal("protocol_init", Assert.Throws(() => ok.Accept(Fixtures.ProbeInit())).Code);
+ }
+
+ [Fact]
+ public void Records_before_init_unknown_records_and_unknown_tools_are_rejected()
+ {
+ var early = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog).Accept(Fixtures.Record("{\"type\":\"system\",\"subtype\":\"commands_changed\"}")));
+ Assert.Equal("protocol_init", early.Code);
+ Assert.Contains("system/commands_changed record before initialization", early.Message);
+
+ var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
+ mapper.Accept(Fixtures.ProbeInit());
+ Assert.Equal("protocol_stream", Assert.Throws(() => mapper.Accept(Fixtures.Record("{\"type\":\"future_protocol_record\"}"))).Code);
+ Assert.Equal("protocol_stream", Assert.Throws(() => mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_stop\",\"index\":0}}"))).Code);
+
+ mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"model\":\"claude-sonnet-5\",\"usage\":{}}}}"));
+ var unknown = Assert.Throws(() => mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"t\",\"name\":\"mcp__pi__other\",\"input\":{}}}}")));
+ Assert.Equal("tool_unknown", unknown.Code);
+ }
+
+ [Fact]
+ public void Result_only_fallback_text_and_error_results()
+ {
+ var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog);
+ mapper.Accept(Fixtures.Init());
+ var events = mapper.Accept(Fixtures.Record("{\"type\":\"result\",\"is_error\":false,\"result\":\"fallback\",\"stop_reason\":\"max_tokens\",\"usage\":{\"input_tokens\":4,\"output_tokens\":2,\"cache_read_input_tokens\":3,\"cache_creation_input_tokens\":5,\"output_tokens_details\":{\"thinking_tokens\":2}},\"modelUsage\":{\"sonnet\":{\"contextWindow\":200000,\"maxOutputTokens\":64000}}}"));
+
+ Assert.Equal(["TextStart", "TextDelta", "TextEnd"], Kinds(events));
+ Assert.True(mapper.HasSuccessfulResult);
+ Assert.Equal(new Usage(4, 2, 3, 5, 2), mapper.Usage);
+ Assert.Equal(200000, mapper.ServedContextWindow);
+ Assert.Equal(64000, mapper.ServedMaxOutputTokens);
+ var done = Assert.IsType(mapper.Complete());
+ Assert.Equal(StopReason.Length, done.Reason);
+ Assert.Equal("fallback", done.Message.Text);
+ Assert.Throws(() => mapper.Complete());
+
+ var failing = new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog);
+ failing.Accept(Fixtures.Init());
+ var ex = Assert.Throws(() => failing.Accept(Fixtures.Record("{\"type\":\"result\",\"is_error\":true,\"api_error_status\":429,\"result\":\"subscription limit reached\"}")));
+ Assert.Equal("protocol_stream", ex.Code);
+ Assert.Contains("(429)", ex.Message);
+ Assert.Contains("subscription limit reached", ex.Message);
+
+ var diagnostic = new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog);
+ diagnostic.Accept(Fixtures.Init());
+ diagnostic.Accept(Fixtures.Record("{\"type\":\"assistant\",\"error\":\"rate_limit\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"You're out of usage credits\"}]}}"));
+ var kept = Assert.Throws(() => diagnostic.Accept(Fixtures.Record("{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":true,\"api_error_status\":429,\"result\":\"\",\"errors\":[\"loop detail\"]}")));
+ Assert.Contains("You're out of usage credits", kept.Message);
+
+ var malformed = new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog);
+ malformed.Accept(Fixtures.Init());
+ Assert.Contains("boolean is_error", Assert.Throws(() => malformed.Accept(Fixtures.Record("{\"type\":\"result\",\"result\":\"missing flag\"}"))).Message);
+ }
+
+ private static StreamEventMapper ReadyToolMapper(string stopReason = "tool_use")
+ {
+ var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
+ mapper.Accept(Fixtures.ProbeInit());
+ mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_tool_ack\",\"model\":\"claude-sonnet-5\",\"usage\":{}}}}"));
+ mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_ack\",\"name\":\"mcp__pi__probe\",\"input\":{}}}}"));
+ mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\":\\\"package.json\\\"}\"}}}"));
+ mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_stop\",\"index\":0}}"));
+ mapper.Accept(Fixtures.Record($"{{\"type\":\"stream_event\",\"event\":{{\"type\":\"message_delta\",\"delta\":{{\"stop_reason\":\"{stopReason}\"}},\"usage\":{{}}}}}}"));
+ return mapper;
+ }
+
+ [Fact]
+ public void Handoff_termination_result_is_accepted_and_other_late_errors_are_not()
+ {
+ var mapper = ReadyToolMapper();
+ Assert.True(mapper.HandoffLatched);
+ var kill = Fixtures.Record("{\"type\":\"result\",\"subtype\":\"error_during_execution\",\"is_error\":true,\"stop_reason\":\"tool_use\",\"terminal_reason\":\"aborted_streaming\",\"usage\":{\"input_tokens\":4,\"output_tokens\":2},\"modelUsage\":{\"sonnet\":{\"contextWindow\":1000000,\"maxOutputTokens\":64000}}}");
+ Assert.Empty(mapper.Accept(kill));
+ Assert.Equal(4, mapper.Usage.Input);
+ Assert.Equal(1000000, mapper.ServedContextWindow);
+ var done = Assert.IsType(mapper.Complete());
+ Assert.Equal(StopReason.ToolUse, done.Reason);
+ Assert.Equal("package.json", done.Message.ToolCalls[0].Arguments.GetProperty("path").GetString());
+ Assert.Equal("protocol_stream", Assert.Throws(() => mapper.Accept(kill)).Code);
+
+ // A retry Claude Code starts for its own next request must not invalidate the proposal.
+ var retrying = ReadyToolMapper();
+ Assert.Empty(retrying.Accept(Fixtures.Record("{\"type\":\"system\",\"subtype\":\"api_retry\",\"error\":\"unknown\",\"error_status\":null}")));
+ Assert.Equal(StopReason.ToolUse, Assert.IsType(retrying.Complete()).Reason);
+
+ var realError = ReadyToolMapper();
+ var ex = Assert.Throws(() => realError.Accept(Fixtures.Record("{\"type\":\"result\",\"subtype\":\"error_during_execution\",\"is_error\":true,\"stop_reason\":\"tool_use\",\"result\":\"real_error\"}")));
+ Assert.Contains("real_error", ex.Message);
+
+ var length = ReadyToolMapper("max_tokens");
+ Assert.Equal(StopReason.Length, length.PendingStop);
+ Assert.Equal(StopReason.Length, Assert.IsType(length.Complete()).Reason);
+ }
+
+ [Fact]
+ public void Thinking_redacted_thinking_and_rate_limits_are_mapped()
+ {
+ var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog);
+ var events = new List(mapper.Accept(Fixtures.Init()));
+ mapper.Accept(Fixtures.Record("{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"allowed\"}}"));
+ mapper.Accept(Fixtures.Record("{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"allowed_warning\",\"rateLimitType\":\"five_hour\",\"utilization\":0.876}}"));
+ mapper.Accept(Fixtures.Record("{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"allowed\",\"overageStatus\":\"rejected\",\"overageDisabledReason\":\"org_level_disabled\",\"isUsingOverage\":false}}"));
+ mapper.Accept(Fixtures.Record("{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"rejected\",\"rateLimitType\":\"five_hour\",\"resetsAt\":1800000000}}"));
+ mapper.Accept(Fixtures.Record("{\"type\":\"rate_limit_event\"}"));
+ events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"model\":\"claude-sonnet-5\",\"usage\":{\"input_tokens\":10}}}}")));
+ events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}}")));
+ events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"plan\"}}}")));
+ events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig\"}}}")));
+ events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_stop\",\"index\":0}}")));
+ events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"redacted_thinking\",\"data\":\"opaque\"}}}")));
+ events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"ping\"}}")));
+ events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_stop\",\"index\":1}}")));
+ events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}}")));
+ events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"message_stop\"}}")));
+ events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"result\",\"is_error\":false,\"result\":\"\"}")));
+
+ Assert.Equal(["Start", "ThinkingStart", "ThinkingDelta", "ThinkingEnd", "ThinkingStart", "ThinkingEnd"], Kinds(events));
+ var done = Assert.IsType(mapper.Complete());
+ var thinking = Assert.IsType(done.Message.Content[0]);
+ Assert.Equal("plan", thinking.Thinking);
+ Assert.Equal("sig", thinking.Signature);
+ Assert.False(thinking.Redacted);
+ var redacted = Assert.IsType(done.Message.Content[1]);
+ Assert.True(redacted.Redacted);
+ Assert.Equal("opaque", redacted.Signature);
+ Assert.Equal(new Usage(10, 5, 0, 0), done.Message.Usage);
+
+ Assert.Equal(2, mapper.Notices.Count);
+ Assert.Contains("87% used", mapper.Notices[0]);
+ Assert.Contains("five_hour", mapper.Notices[1]);
+ Assert.Contains("five_hour", mapper.RateLimitFailure);
+ Assert.Contains("2027-", mapper.RateLimitFailure);
+ // A rejected rate limit is a diagnostic, not a held failure: the successful result above still completed.
+ Assert.Null(mapper.DeferredError);
+ }
+
+ [Fact]
+ public void Overflow_normalisation_prefixes_claude_wordings_once()
+ {
+ Assert.Equal("context_length_exceeded: API Error: Prompt is too long", StreamEventMapper.NormalizeOverflowMessage("API Error: Prompt is too long"));
+ Assert.Equal("context_length_exceeded: x", StreamEventMapper.NormalizeOverflowMessage("context_length_exceeded: x"));
+ Assert.Equal("rate limited", StreamEventMapper.NormalizeOverflowMessage("rate limited"));
+ Assert.True(StreamEventMapper.IsOverflow("input length and max_tokens exceed the context limit"));
+ }
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ToolCatalogTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ToolCatalogTests.cs
new file mode 100644
index 0000000..78a30d4
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ToolCatalogTests.cs
@@ -0,0 +1,117 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Volcanic.AgenticCore.Abstractions.Providers;
+using Volcanic.AgenticCore.Extensions.ClaudeCode;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
+
+public class ToolCatalogTests
+{
+ private static readonly JsonElement Schema = Fixtures.Record("{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}}}");
+
+ private static ToolDescriptor Tool(string name, string description = "d") => new(name, description, Schema);
+
+ private static string Digest16(string name) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(name)))[..16];
+
+ [Fact]
+ public void Invalid_names_get_a_digest_alias()
+ {
+ var catalog = new ToolCatalog([Tool("bad name!")]);
+ var alias = Assert.Single(catalog.Aliases);
+ Assert.Equal($"tool_{Digest16("bad name!")}", alias);
+ Assert.Matches("^tool_[a-f0-9]{16}$", alias);
+ Assert.Equal($"mcp__volcanic__{alias}", Assert.Single(catalog.TransportNames));
+ Assert.Equal("bad name!", catalog.TransportToTool[$"mcp__volcanic__{alias}"]);
+ }
+
+ [Fact]
+ public void Valid_names_are_kept_and_the_alias_regex_matches_claude_codes_rules()
+ {
+ Assert.Equal("read_file", ToolCatalog.AliasFor("read_file"));
+ Assert.Equal("Bash-2", ToolCatalog.AliasFor("Bash-2"));
+ Assert.StartsWith("tool_", ToolCatalog.AliasFor("foo.bar"));
+ Assert.StartsWith("tool_", ToolCatalog.AliasFor(new string('a', 49)));
+ Assert.Equal(new string('a', 48), ToolCatalog.AliasFor(new string('a', 48)));
+ }
+
+ [Fact]
+ public void Collisions_are_suffixed_and_every_alias_stays_unique()
+ {
+ // A valid name that equals another tool's digest alias collides with it; the later (sorted) one gets _2.
+ var digest = $"tool_{Digest16("bad name!")}";
+ var catalog = new ToolCatalog([Tool(digest), Tool("bad name!"), Tool(digest + "_2")]);
+
+ Assert.Equal(["bad name!", digest, digest + "_2"], catalog.Tools.Select(t => t.Name).ToArray());
+ Assert.Equal([digest, digest + "_2", digest + "_2_2"], catalog.Aliases.ToArray());
+ Assert.Equal(3, catalog.TransportToTool.Count);
+ Assert.Equal("bad name!", catalog.TransportToTool[$"mcp__volcanic__{digest}"]);
+ Assert.Equal(digest, catalog.TransportToTool[$"mcp__volcanic__{digest}_2"]);
+ Assert.Equal(digest + "_2", catalog.TransportToTool[$"mcp__volcanic__{digest}_2_2"]);
+ Assert.All(catalog.Aliases, alias => Assert.True(alias.Length <= 48));
+ }
+
+ [Fact]
+ public void Suffixes_keep_long_aliases_within_the_limit()
+ {
+ var longName = new string('a', 48);
+ var catalog = new ToolCatalog([Tool(longName), Tool(longName + "!")]);
+
+ // "aaa...!" is invalid (49 chars) and gets a digest, so no collision; both aliases are within 48 chars.
+ Assert.Equal(2, catalog.Aliases.Distinct().Count());
+ Assert.All(catalog.Aliases, alias => Assert.True(alias.Length <= 48));
+ }
+
+ [Fact]
+ public void Transport_names_round_trip_and_honour_the_server_name()
+ {
+ var catalog = new ToolCatalog([Tool("probe")], "pi");
+ Assert.Equal("mcp__pi__probe", catalog.ToolToTransport["probe"]);
+ Assert.Equal("probe", catalog.TransportToTool["mcp__pi__probe"]);
+ Assert.Equal("mcp__pi__", catalog.TransportPrefix);
+ Assert.False(catalog.TransportToTool.ContainsKey("mcp__volcanic__probe"));
+ foreach (var (tool, transport) in catalog.ToolToTransport) Assert.Equal(tool, catalog.TransportToTool[transport]);
+ }
+
+ [Fact]
+ public void Tools_are_sorted_by_name_and_serialised_as_name_description_inputSchema()
+ {
+ var catalog = new ToolCatalog([Tool("zeta", "z"), Tool("alpha", "a"), Tool("Beta", "b")]);
+ Assert.Equal(["Beta", "alpha", "zeta"], catalog.Tools.Select(t => t.Name).ToArray());
+
+ var json = JsonSerializer.Deserialize(catalog.ToJson());
+ Assert.Equal(JsonValueKind.Array, json.ValueKind);
+ Assert.Equal(3, json.GetArrayLength());
+ var first = json[0];
+ Assert.Equal(["name", "description", "inputSchema"], first.EnumerateObject().Select(p => p.Name).ToArray());
+ Assert.Equal("Beta", first.GetProperty("name").GetString());
+ Assert.Equal("b", first.GetProperty("description").GetString());
+ Assert.Equal("object", first.GetProperty("inputSchema").GetProperty("type").GetString());
+ Assert.Equal("string", first.GetProperty("inputSchema").GetProperty("properties").GetProperty("value").GetProperty("type").GetString());
+ }
+
+ [Fact]
+ public void Empty_catalogue_has_no_transport_names()
+ {
+ var catalog = new ToolCatalog([]);
+ Assert.True(catalog.IsEmpty);
+ Assert.Empty(catalog.TransportNames);
+ Assert.Equal("[]", catalog.ToJson());
+ }
+
+ [Fact]
+ public void Limits_and_duplicates_are_rejected()
+ {
+ var tooMany = Enumerable.Range(0, ToolCatalog.MaxTools + 1).Select(i => Tool($"t{i}")).ToList();
+ Assert.Equal("protocol_init", Assert.Throws(() => new ToolCatalog(tooMany)).Code);
+
+ var duplicate = Assert.Throws(() => new ToolCatalog([Tool("same"), Tool("same")]));
+ Assert.Contains("Duplicate", duplicate.Message);
+
+ var huge = Tool("big", new string('d', ToolCatalog.MaxCatalogBytes));
+ Assert.Equal("protocol_init", Assert.Throws(() => new ToolCatalog([huge])).Code);
+
+ var notObject = new ToolDescriptor("x", "d", Fixtures.Record("[]"));
+ Assert.Throws(() => new ToolCatalog([notObject]));
+ }
+}