using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace Gott.Services; /// /// Service für die Kommunikation mit der Claude Code CLI. /// Verwendet die CLI als Backend für OAuth-basierte Chats (kein API-Key nötig). /// public class ClaudeCliService { private readonly ILogger _logger; private readonly string _cliPath; private readonly bool _isAvailable; public ClaudeCliService(ILogger logger) { _logger = logger; _cliPath = FindCliPath(); _isAvailable = !string.IsNullOrEmpty(_cliPath) && File.Exists(_cliPath); if (_isAvailable) _logger.LogInformation("Claude CLI gefunden: {Path}", _cliPath); else _logger.LogWarning("Claude CLI nicht gefunden. CLI-basierte Chats sind nicht verfügbar."); } public bool IsAvailable => _isAvailable; public string CliPath => _cliPath; /// /// Aktiviert detailliertes Encoding-Debugging für CLI-stdout. /// Schreibt alle rohen Bytes vom CLI-Prozess in eine .bin-Datei und /// loggt jedes Vorkommen von U+FFFD (Replacement-Char) in eine .log-Datei. /// Aktivierung: Umgebungsvariable GOTT_CLI_DEBUG_BYTES=1 /// private static bool IsByteDebugEnabled() { var v = Environment.GetEnvironmentVariable("GOTT_CLI_DEBUG_BYTES"); return !string.IsNullOrEmpty(v) && (v == "1" || string.Equals(v, "true", StringComparison.OrdinalIgnoreCase)); } private static string GetDebugDir() { // data/cli-debug/ relativ zur Anwendung – wird automatisch erstellt var dir = Path.Combine(AppContext.BaseDirectory, "data", "cli-debug"); Directory.CreateDirectory(dir); return dir; } private ProcessStartInfo CreateCliStartInfo(List args, bool redirectInput = false, string effort = null, string workingDirectory = null) { var psi = new ProcessStartInfo { FileName = _cliPath, RedirectStandardInput = redirectInput, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; // Arbeitsverzeichnis explizit auf den Workspace setzen. // Ohne das erbt der CLI-Child das CWD des gott-Servers (unter Windows das // Dienst-/Installationsverzeichnis), wodurch RELATIVE Tool-Pfade wie // Glob "Sources/**" ins Leere laufen ("No files found"), während absolute // Pfade funktionieren. --add-dir erteilt nur Zugriff, setzt aber NICHT das CWD. if (!string.IsNullOrEmpty(workingDirectory) && Directory.Exists(workingDirectory)) psi.WorkingDirectory = workingDirectory; // 1h-Prompt-Cache-TTL für Claude CLI aktivieren (seit CLI 2.1.108). // Anthropic hat Anfang März 2026 den Default von 1h auf 5min gesenkt, // dieser Flag holt die 1h-TTL zurück (gilt für API-Key, Bedrock, Vertex, Foundry). psi.Environment["ENABLE_PROMPT_CACHING_1H"] = "1"; // Effort-Level via Env-Var setzen (höchste Priorität, deckt auch "max" und "ultracode" ab). // Das "--effort"-Flag unterstützt nur low/medium/high/xhigh – Env-Var deckt alles. if (!string.IsNullOrEmpty(effort) && effort != "high") psi.Environment["CLAUDE_CODE_EFFORT_LEVEL"] = effort; foreach (var arg in args) psi.ArgumentList.Add(arg); return psi; } private static string FindCliPath() { // 1. Expliziter Pfad über Umgebungsvariable (höchste Priorität) var envPath = Environment.GetEnvironmentVariable("CLAUDE_CLI_PATH"); if (!string.IsNullOrEmpty(envPath) && File.Exists(envPath)) return envPath; // 2. Bekannte Installationspfade var possiblePaths = new[] { // Windows: standalone install Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "claude-code", "claude.exe"), // Windows/Linux: local bin (funktioniert nur wenn Dienst unter dem richtigen User läuft) Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude"), // Linux: system paths "/usr/local/bin/claude", "/usr/bin/claude" }; foreach (var path in possiblePaths) { if (File.Exists(path)) return path; } // 3. PATH-Suche try { var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? ""; foreach (var dir in pathEnv.Split(Path.PathSeparator)) { var exePath = Path.Combine(dir, "claude.exe"); if (File.Exists(exePath)) return exePath; exePath = Path.Combine(dir, "claude"); if (File.Exists(exePath)) return exePath; } } catch { } return null; } /// /// Streaming-Chat über die Claude CLI mit Event-basiertem Callback. /// Unterstützt Text-Streaming und Tool-Use Events. /// public async Task ChatStreamingAsync( string prompt, Func onEvent, string modelId = null, string systemPrompt = null, string workspace = null, bool hasWorkspace = false, bool planMode = false, bool webSearch = false, string mcpConfigPath = null, bool hasUnityMcp = false, string effort = null, CancellationToken cancellationToken = default) { if (!_isAvailable) return new CliChatResult { Success = false, Error = "Claude CLI nicht verfügbar" }; // Permission-Mode: Im Plan-Modus (auch Panel-Member laufen read-only via planMode=true) // MUSS die Schreib-Sperre über die Permission-Ebene erzwungen werden. 'acceptEdits' würde // Write/Edit AUTOMATISCH akzeptieren – UNABHÄNGIG von --allowed-tools – sodass ein als // read-only gedachter Member (oder ein regulärer Plan-Modus-User) trotzdem Dateien schreiben // könnte. 'plan' blockiert alle Schreib-/Edit-/Bash-Operationen auf Permission-Ebene. var permissionMode = planMode ? "plan" : "acceptEdits"; var args = new List { "-p", "--verbose", "--output-format", "stream-json", "--include-partial-messages", "--no-session-persistence", "--permission-mode", permissionMode, "--disallowed-tools", "AskUserQuestion" }; // Tools konfigurieren var tools = new List(); if (hasWorkspace && planMode) { // Plan-Modus: nur read-only Tools tools.AddRange(new[] { "Read", "Glob", "Grep" }); } else if (hasWorkspace) { // Normaler Modus mit Workspace: alle File-Tools tools.AddRange(new[] { "Bash(*)", "Read", "Write", "Edit", "Glob", "Grep" }); } else { // Kein Workspace: Read für Temp-Dateien (z.B. gesendete Bilder) tools.Add("Read"); } if (webSearch) { tools.Add("WebFetch"); tools.Add("WebSearch"); } // MCP-Tools erlauben (wenn MCP-Config vorhanden) if (!string.IsNullOrEmpty(mcpConfigPath)) { tools.Add("mcp__gott-remote__remote_execute"); tools.Add("mcp__gott-remote__ask_user_choice"); tools.Add("mcp__gott-remote__memory_read"); tools.Add("mcp__gott-remote__memory_write"); tools.Add("mcp__gott-remote__memory_list"); } // Unity MCP Tools erlauben (alle Tools vom unity MCP-Server) if (hasUnityMcp) { tools.Add("mcp__unity__*"); } args.Add("--allowed-tools"); args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\""); if (!string.IsNullOrEmpty(workspace)) { args.Add("--add-dir"); args.Add(workspace); } // Temp-Ordner immer zugänglich machen (für gesendete Bilder etc.) var tempImageDir = Path.Combine(Path.GetTempPath(), "gott-cli-images"); if (Directory.Exists(tempImageDir)) { args.Add("--add-dir"); args.Add(tempImageDir); } if (!string.IsNullOrEmpty(modelId)) { args.Add("--model"); args.Add(modelId); } if (!string.IsNullOrEmpty(systemPrompt)) { args.Add("--system-prompt"); args.Add(systemPrompt); } if (!string.IsNullOrEmpty(mcpConfigPath)) { args.Add("--mcp-config"); args.Add(mcpConfigPath); } return await RunCliStreamingAsync(args, prompt, onEvent, cancellationToken, effort, workspace); } private async Task RunCliStreamingAsync( List args, string stdinPrompt, Func onEvent, CancellationToken cancellationToken, string effort = null, string workspace = null) { var result = new CliChatResult(); var fullOutput = new StringBuilder(); var error = new StringBuilder(); // Tracking für content blocks string currentBlockType = null; // "text" oder "tool_use" string currentToolName = null; string currentToolId = null; var currentToolInput = new StringBuilder(); bool inTextBlock = false; var lineBuffer = new StringBuilder(); // Encoding-Debug Cleanup-Holder (im finally geschlossen) StreamWriter debugMarkerWriter = null; try { var startInfo = CreateCliStartInfo(args, redirectInput: true, effort: effort, workingDirectory: workspace); using var process = new Process { StartInfo = startInfo }; process.ErrorDataReceived += (s, e) => { if (e.Data != null) { error.AppendLine(e.Data); _logger.LogDebug("CLI stderr: {Line}", e.Data); } }; process.Start(); process.BeginErrorReadLine(); // Bei Cancellation den gesamten Prozessbaum beenden // (ReadLineAsync wirft OperationCanceledException, aber process.Dispose() killt NICHT den Prozess) using var killOnCancel = cancellationToken.Register(() => { try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { } }); await process.StandardInput.WriteAsync(stdinPrompt); await process.StandardInput.FlushAsync(); process.StandardInput.Close(); // === ENCODING-DIAGNOSE === (nur aktiv wenn GOTT_CLI_DEBUG_BYTES=1) // Wenn aktiviert, alle rohen Bytes in eine .bin-Datei teen und Marker-Log // für jedes Vorkommen von U+FFFD schreiben. So können wir feststellen, ob das // Replacement-Char schon vom Claude-CLI kommt (.bin enthält bereits invalide // UTF-8-Bytes) oder erst beim Decode entsteht (.bin ist sauber, aber decoded // line hat �). Im Default-Modus (env nicht gesetzt) bleibt der ursprüngliche // Default-Reader aktiv – kein zusätzlicher Disk-IO im Normalbetrieb. bool debugBytes = IsByteDebugEnabled(); FileStream rawStream = null; StreamWriter markerWriter = null; string debugSessionId = null; if (debugBytes) { try { debugSessionId = DateTime.Now.ToString("yyyyMMdd-HHmmss-fff"); var debugDir = GetDebugDir(); var rawPath = Path.Combine(debugDir, $"cli-stdout-{debugSessionId}.bin"); var markerPath = Path.Combine(debugDir, $"cli-stdout-{debugSessionId}.log"); rawStream = new FileStream(rawPath, FileMode.Create, FileAccess.Write, FileShare.Read); markerWriter = new StreamWriter(markerPath, append: false, new UTF8Encoding(false)) { AutoFlush = true }; debugMarkerWriter = markerWriter; // für finally-Cleanup markerWriter.WriteLine($"# CLI Encoding Debug Log – Session {debugSessionId}"); markerWriter.WriteLine($"# Roh-Bytes: {Path.GetFileName(rawPath)}"); markerWriter.WriteLine($"# Args: {string.Join(" ", args)}"); markerWriter.WriteLine($"# OS: {RuntimeInformation.OSDescription}, ACP={Encoding.Default.CodePage}, OEM={Console.OutputEncoding.CodePage}"); markerWriter.WriteLine(); _logger.LogWarning("CLI encoding debug aktiv. Roh-Bytes → {Raw}, Marker-Log → {Log}", rawPath, markerPath); } catch (Exception ex) { _logger.LogWarning(ex, "Konnte CLI-Debug-Logging nicht initialisieren – fahre ohne fort"); debugBytes = false; rawStream?.Dispose(); rawStream = null; markerWriter?.Dispose(); markerWriter = null; } } // Im Debug-Modus: Custom-Reader mit TeeStream (16 KB Buffer + UTF-8 ohne BOM-Detection). // Im Default-Modus: ursprünglicher Reader (= process.StandardOutput) – kein Encoding- // override, weil die diversen Härtungsversuche (chcp, UTF-8-Reader, Console.OutputEncoding) // das �-Problem nachweislich nicht behoben haben. Wir warten auf das Diagnose-Ergebnis, // bevor wir den nächsten Fix-Versuch starten. using StreamReader reader = (debugBytes && rawStream != null) ? new StreamReader( new TeeReadStream(process.StandardOutput.BaseStream, rawStream, leaveTeeOpen: false), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), detectEncodingFromByteOrderMarks: false, bufferSize: 16384, leaveOpen: false) : process.StandardOutput; string line; int lineNum = 0; while ((line = await reader.ReadLineAsync(cancellationToken)) != null) { lineNum++; // Marker-Log: U+FFFD im decoded Output erkennen if (debugBytes && markerWriter != null && line.IndexOf('�') >= 0) { try { var firstAt = line.IndexOf('�'); int ctxStart = Math.Max(0, firstAt - 40); int ctxEnd = Math.Min(line.Length, firstAt + 40); var ctx = line.Substring(ctxStart, ctxEnd - ctxStart); // U+FFFD im Kontext durch sichtbares Marker ersetzen für die Log-Zeile var ctxVis = ctx.Replace("�", "<>"); markerWriter.WriteLine($"[Line {lineNum}] FFFD count={CountChar(line, '�')} firstPos={firstAt} totalLen={line.Length}"); markerWriter.WriteLine($" Context: ...{ctxVis}..."); markerWriter.WriteLine($" Full line (first 500 chars): {(line.Length > 500 ? line.Substring(0, 500) : line).Replace("�", "<>")}"); markerWriter.WriteLine(); _logger.LogWarning("CLI U+FFFD detected on line {Ln} at pos {Pos}: ...{Ctx}...", lineNum, firstAt, ctxVis); } catch { } } if (!line.StartsWith("{")) continue; try { var json = JsonSerializer.Deserialize(line); var type = json.TryGetProperty("type", out var t) ? t.GetString() : null; if (type == "stream_event" && json.TryGetProperty("event", out var evt)) { var evtType = evt.TryGetProperty("type", out var et) ? et.GetString() : null; switch (evtType) { case "content_block_start": { if (evt.TryGetProperty("content_block", out var cb)) { var cbType = cb.TryGetProperty("type", out var cbt) ? cbt.GetString() : null; if (cbType == "text") { currentBlockType = "text"; if (!inTextBlock) { inTextBlock = true; await onEvent(new CliStreamEvent { Type = CliEventType.TextStart }); } } else if (cbType == "tool_use") { // Text-Block vorher abschließen + lineBuffer flushen if (inTextBlock) { // Restlichen Text im lineBuffer emittieren bevor Tool startet if (lineBuffer.Length > 0) { var remaining = lineBuffer.ToString(); lineBuffer.Clear(); if (remaining.Trim().Length > 0) { fullOutput.Append(remaining); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining }); } } inTextBlock = false; await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd }); } currentBlockType = "tool_use"; currentToolName = cb.TryGetProperty("name", out var n) ? n.GetString() : "unknown"; currentToolId = cb.TryGetProperty("id", out var id) ? id.GetString() : ""; currentToolInput.Clear(); await onEvent(new CliStreamEvent { Type = CliEventType.ToolStart, ToolName = currentToolName, ToolId = currentToolId }); } } break; } case "content_block_delta": { if (evt.TryGetProperty("delta", out var delta)) { var deltaType = delta.TryGetProperty("type", out var dt) ? dt.GetString() : null; if (deltaType == "text_delta" && delta.TryGetProperty("text", out var txt)) { var text = txt.GetString(); if (!string.IsNullOrEmpty(text)) { for (int ci = 0; ci < text.Length; ci++) { var ch = text[ci]; if (ch == '\n') { var completedLine = lineBuffer.ToString().Trim(); lineBuffer.Clear(); if (completedLine.Length > 0 || fullOutput.Length > 0) { var lineText = completedLine + "\n"; fullOutput.Append(lineText); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = lineText }); } } else { lineBuffer.Append(ch); } } } } else if (deltaType == "input_json_delta" && delta.TryGetProperty("partial_json", out var pj)) { currentToolInput.Append(pj.GetString()); } } break; } case "content_block_stop": { if (currentBlockType == "tool_use") { await onEvent(new CliStreamEvent { Type = CliEventType.ToolEnd, ToolName = currentToolName, ToolId = currentToolId, ToolInput = currentToolInput.ToString() }); currentBlockType = null; currentToolName = null; currentToolId = null; currentToolInput.Clear(); } else if (currentBlockType == "text") { // lineBuffer flushen (Text ohne trailing \n) if (lineBuffer.Length > 0) { var remaining = lineBuffer.ToString(); lineBuffer.Clear(); if (remaining.Trim().Length > 0) { fullOutput.Append(remaining); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining }); } } currentBlockType = null; } break; } } } else if (type == "result") { // Restlichen lineBuffer flushen (nicht-abgeschlossene Zeile) if (lineBuffer.Length > 0) { var remaining = lineBuffer.ToString().Trim(); lineBuffer.Clear(); if (remaining.Length > 0) { fullOutput.Append(remaining); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining }); } } // Offenen Text-Block abschließen if (inTextBlock) { inTextBlock = false; await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd }); } if (json.TryGetProperty("usage", out var usage)) { result.InputTokens = usage.TryGetProperty("input_tokens", out var it) ? it.GetInt64() : 0; result.OutputTokens = usage.TryGetProperty("output_tokens", out var ot) ? ot.GetInt64() : 0; result.CacheReadTokens = usage.TryGetProperty("cache_read_input_tokens", out var cr) ? cr.GetInt64() : 0; result.CacheCreationTokens = usage.TryGetProperty("cache_creation_input_tokens", out var cc) ? cc.GetInt64() : 0; } if (json.TryGetProperty("total_cost_usd", out var cost)) result.CostUsd = cost.GetDouble(); if (json.TryGetProperty("duration_api_ms", out var dur)) result.DurationApiMs = dur.GetInt64(); if (json.TryGetProperty("session_id", out var sid)) result.SessionId = sid.GetString(); if (json.TryGetProperty("num_turns", out var nt)) result.NumTurns = nt.GetInt32(); var isError = json.TryGetProperty("is_error", out var ie) && ie.GetBoolean(); if (isError) result.Error = json.TryGetProperty("result", out var errMsg) ? errMsg.GetString() : "CLI error"; } else if (type == "user" && json.TryGetProperty("message", out var userMsg)) { // CLI sendet tool_result als type:"user" message if (userMsg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array) { foreach (var item in content.EnumerateArray()) { var itemType = item.TryGetProperty("type", out var iType) ? iType.GetString() : null; if (itemType == "tool_result") { var toolUseId = item.TryGetProperty("tool_use_id", out var tuId) ? tuId.GetString() : ""; var resultIsError = item.TryGetProperty("is_error", out var isErr) && isErr.GetBoolean(); // Content kann string oder array sein var resultContent = ""; if (item.TryGetProperty("content", out var rc)) { if (rc.ValueKind == JsonValueKind.String) resultContent = rc.GetString(); else if (rc.ValueKind == JsonValueKind.Array) { var sb = new StringBuilder(); foreach (var part in rc.EnumerateArray()) { if (part.TryGetProperty("text", out var partText)) sb.Append(partText.GetString()); } resultContent = sb.ToString(); } } await onEvent(new CliStreamEvent { Type = CliEventType.ToolResult, ToolId = toolUseId, ToolResultContent = resultContent, ToolResultIsError = resultIsError }); } } } } } catch (JsonException ex) { _logger.LogDebug("CLI JSON parse error: {Error} for line: {Line}", ex.Message, line.Length > 200 ? line[..200] : line); } } // Restlichen lineBuffer flushen if (lineBuffer.Length > 0) { var remaining = lineBuffer.ToString().Trim(); lineBuffer.Clear(); if (remaining.Length > 0) { fullOutput.Append(remaining); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining }); } } // Sicherstellen, dass offene Blöcke geschlossen werden if (currentBlockType == "tool_use" && currentToolName != null) { await onEvent(new CliStreamEvent { Type = CliEventType.ToolEnd, ToolName = currentToolName, ToolId = currentToolId, ToolInput = currentToolInput.ToString() }); currentBlockType = null; } if (inTextBlock) await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd }); await process.WaitForExitAsync(cancellationToken); result.ExitCode = process.ExitCode; result.Output = fullOutput.ToString().Trim(); result.Success = process.ExitCode == 0 && fullOutput.Length > 0; if (!result.Success && string.IsNullOrEmpty(result.Error)) result.Error = error.Length > 0 ? error.ToString().Trim() : $"CLI exit code: {process.ExitCode}"; } catch (OperationCanceledException) { result.Success = false; result.Error = "Abgebrochen"; throw; } catch (Exception ex) { result.Success = false; result.Error = ex.Message; _logger.LogError(ex, "Fehler beim Streaming der Claude CLI"); } finally { // Encoding-Debug Marker-Writer abschließen (rawStream wird vom TeeReadStream-Dispose geschlossen) if (debugMarkerWriter != null) { try { debugMarkerWriter.WriteLine(); debugMarkerWriter.WriteLine($"# End. fullOutput length = {fullOutput.Length}, FFFD count = {CountChar(fullOutput.ToString(), '�')}"); debugMarkerWriter.Flush(); debugMarkerWriter.Dispose(); } catch { } } } return result; } private static int CountChar(string s, char c) { if (string.IsNullOrEmpty(s)) return 0; int n = 0; for (int i = 0; i < s.Length; i++) if (s[i] == c) n++; return n; } /// /// Prüft ob die CLI funktioniert und authentifiziert ist. /// public async Task GetStatusAsync() { var result = new CliStatusResult { IsAvailable = _isAvailable }; if (!_isAvailable) { result.Error = "Claude CLI nicht gefunden"; return result; } try { var versionResult = await RunSimpleAsync(["--version"], CancellationToken.None); if (versionResult.Success) result.Version = versionResult.Output.Trim(); var doctorResult = await RunSimpleAsync(["doctor"], CancellationToken.None); result.IsAuthenticated = doctorResult.Success; if (!doctorResult.Success) result.Error = doctorResult.Error ?? doctorResult.Output; } catch (Exception ex) { result.Error = ex.Message; } return result; } private async Task RunSimpleAsync(List args, CancellationToken cancellationToken) { var result = new CliChatResult(); var output = new StringBuilder(); var error = new StringBuilder(); try { var startInfo = CreateCliStartInfo(args); using var process = new Process { StartInfo = startInfo }; process.OutputDataReceived += (s, e) => { if (e.Data != null) output.AppendLine(e.Data); }; process.ErrorDataReceived += (s, e) => { if (e.Data != null) error.AppendLine(e.Data); }; process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); using var killOnCancel = cancellationToken.Register(() => { try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { } }); await process.WaitForExitAsync(cancellationToken); result.ExitCode = process.ExitCode; result.Output = output.ToString().Trim(); result.Success = process.ExitCode == 0; if (!result.Success) result.Error = error.Length > 0 ? error.ToString().Trim() : $"Exit code: {process.ExitCode}"; } catch (OperationCanceledException) { result.Success = false; result.Error = "Abgebrochen"; throw; } catch (Exception ex) { result.Success = false; result.Error = ex.Message; _logger.LogError(ex, "Fehler beim Ausführen der Claude CLI"); } return result; } } /// /// Stream-Wrapper, der jedes gelesene Byte parallel in einen zweiten Stream (Tee-File) schreibt. /// Wird für Encoding-Diagnose verwendet, um die rohen Bytes vom Claude-CLI-Prozess zu konservieren, /// bevor sie durch den UTF-8-StreamReader gehen. /// internal sealed class TeeReadStream : Stream { private readonly Stream _source; private readonly Stream _tee; private readonly bool _leaveTeeOpen; private long _totalRead; public TeeReadStream(Stream source, Stream tee, bool leaveTeeOpen = false) { _source = source ?? throw new ArgumentNullException(nameof(source)); _tee = tee ?? throw new ArgumentNullException(nameof(tee)); _leaveTeeOpen = leaveTeeOpen; } public long TotalRead => _totalRead; public override bool CanRead => _source.CanRead; public override bool CanSeek => false; public override bool CanWrite => false; public override long Length => throw new NotSupportedException(); public override long Position { get => _totalRead; set => throw new NotSupportedException(); } public override int Read(byte[] buffer, int offset, int count) { var n = _source.Read(buffer, offset, count); if (n > 0) { _tee.Write(buffer, offset, n); _tee.Flush(); _totalRead += n; } return n; } public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { var n = await _source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); if (n > 0) { await _tee.WriteAsync(buffer.Slice(0, n), cancellationToken).ConfigureAwait(false); await _tee.FlushAsync(cancellationToken).ConfigureAwait(false); _totalRead += n; } return n; } public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { var n = await _source.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); if (n > 0) { await _tee.WriteAsync(buffer.AsMemory(offset, n), cancellationToken).ConfigureAwait(false); await _tee.FlushAsync(cancellationToken).ConfigureAwait(false); _totalRead += n; } return n; } public override void Flush() { /* read-only */ } public override int Read(Span buffer) { var n = _source.Read(buffer); if (n > 0) { _tee.Write(buffer.Slice(0, n)); _tee.Flush(); _totalRead += 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(); protected override void Dispose(bool disposing) { if (disposing) { try { _source.Dispose(); } catch { } try { if (!_leaveTeeOpen) _tee.Dispose(); else _tee.Flush(); } catch { } } base.Dispose(disposing); } } public enum CliEventType { TextStart, TextDelta, TextEnd, ToolStart, ToolEnd, ToolResult } public class CliStreamEvent { public CliEventType Type { get; set; } public string Text { get; set; } public string ToolName { get; set; } public string ToolId { get; set; } public string ToolInput { get; set; } public string ToolResultContent { get; set; } public bool ToolResultIsError { get; set; } } public class CliChatResult { public bool Success { get; set; } public string Output { get; set; } public string Error { get; set; } public int ExitCode { get; set; } public long InputTokens { get; set; } public long OutputTokens { get; set; } public long CacheReadTokens { get; set; } public long CacheCreationTokens { get; set; } public double CostUsd { get; set; } public long DurationApiMs { get; set; } public string SessionId { get; set; } public int NumTurns { get; set; } } public class CliStatusResult { public bool IsAvailable { get; set; } public bool IsAuthenticated { get; set; } public string Version { get; set; } public string Error { get; set; } }