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; /// /// Benutzerdefinierter MCP-Server (pro Session). URL-basiert (http/sse), optionaler Bearer-Token. /// public class McpServerDef { public string Name { get; set; } public string Url { get; set; } public string Token { get; set; } } /// /// 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; private readonly ServerSettingsService _settings; private readonly WorkflowProgressTracker _workflowTracker; public ClaudeCliService(ILogger logger, ServerSettingsService settings, WorkflowProgressTracker workflowTracker = null) { _logger = logger; _settings = settings; _workflowTracker = workflowTracker; _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; } /// /// Erkennt z.ai/GLM-Modelle. Diese laufen über den Anthropic-kompatiblen z.ai-Endpoint, /// nicht über das normale Claude-OAuth-Login. /// public static bool IsZaiModel(string modelId) => !string.IsNullOrEmpty(modelId) && modelId.StartsWith("glm-", System.StringComparison.OrdinalIgnoreCase); /// /// GLM-5.2 (und höher) hat (laut z.ai-Doku) ein Reasoning-Effort-Mapping: /// Claude Code mappt low/medium/high → GLM-"high", xhigh/max → GLM-"max". Ältere GLM-Modelle /// (4.x, 5, 5-turbo, 5.1) kennen kein Effort. Bewusst ein Versionsvergleich statt Prefix-Match, /// damit künftige Releases (5.3, 6, …) automatisch mitgenommen werden. Erfasst auch /// Varianten-Suffixe wie "glm-5.2[1m]" oder "glm-5-turbo". /// private static bool IsZaiEffortModel(string modelId) { if (string.IsNullOrEmpty(modelId) || !modelId.StartsWith("glm-", System.StringComparison.OrdinalIgnoreCase)) return false; // Führende Versionsziffern nach "glm-" extrahieren: "5.2[1m]" → 5.2, "5-turbo" → 5(.0). var rest = modelId.Substring(4); int i = 0; while (i < rest.Length && (char.IsDigit(rest[i]) || rest[i] == '.')) i++; var versionStr = rest.Substring(0, i).Trim('.'); if (versionStr.Length == 0) return false; var parts = versionStr.Split('.'); if (!int.TryParse(parts[0], out var major)) return false; var minor = 0; if (parts.Length > 1) int.TryParse(parts[1], out minor); // GLM-5.2 oder höher. return major > 5 || (major == 5 && minor >= 2); } /// /// Erkennt den tool_result des dynamischen Workflow-Tools ("Workflow launched in background … /// Transcript dir: …") und registriert den Lauf im WorkflowProgressTracker. Der Workflow läuft /// als detachter Hintergrund-Task mit eigenem Transcript – seine Live-/End-Telemetrie wird /// später aus den dort liegenden Dateien (journal.jsonl / <runId>.json) gelesen, nicht aus /// dem Stream. Rein additiv – wirft nie (Telemetrie darf den Stream-Parser nicht stören). /// private void TapWorkflowLaunch(string sessionId, string chainId, string resultContent) { try { if (_workflowTracker == null || string.IsNullOrEmpty(sessionId) || string.IsNullOrEmpty(resultContent)) return; if (resultContent.IndexOf("Workflow launched", StringComparison.OrdinalIgnoreCase) < 0) return; // Zeile "Transcript dir: " extrahieren. string transcriptDir = null; foreach (var raw in resultContent.Split('\n')) { var line = raw.Trim(); const string marker = "Transcript dir:"; if (line.StartsWith(marker, StringComparison.OrdinalIgnoreCase)) { transcriptDir = line.Substring(marker.Length).Trim(); break; } } if (!string.IsNullOrEmpty(transcriptDir)) _workflowTracker.Register(sessionId, chainId, transcriptDir); } catch { /* Telemetrie darf den Stream nie stören */ } } private ProcessStartInfo CreateCliStartInfo(List args, bool redirectInput = false, string effort = null, string workingDirectory = null, string modelId = 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; // z.ai / GLM: das Anthropic-kompatible z.ai-Backend NUR für diesen Prozess aktivieren. // Da gott pro Request einen eigenen CLI-Prozess startet, gilt das ausschließlich für // diesen Lauf – das Claude-OAuth-Login aller anderen Requests bleibt unberührt. if (IsZaiModel(modelId)) { var zaiKey = _settings?.Get("ZaiApiKey"); if (!string.IsNullOrEmpty(zaiKey)) { psi.Environment["ANTHROPIC_BASE_URL"] = "https://api.z.ai/api/anthropic"; psi.Environment["ANTHROPIC_AUTH_TOKEN"] = zaiKey; // Interne CLI-Hilfsaufrufe (z.B. Haiku-Klasse für Titel/Utility) auf GLM mappen, // damit sie nicht gegen unbekannte Claude-Modellnamen bei z.ai laufen. psi.Environment["ANTHROPIC_DEFAULT_SONNET_MODEL"] = modelId; psi.Environment["ANTHROPIC_DEFAULT_OPUS_MODEL"] = modelId; psi.Environment["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "glm-4.5-air"; // Effort/Reasoning: NUR GLM-5.2 hat ein definiertes Effort-Mapping bei z.ai // (Claude Code mappt es selbst: low/medium/high → "high", xhigh/max → "max"). // Für 5.2 reichen wir CLAUDE_CODE_EFFORT_LEVEL daher durch; alle anderen GLM-Modelle // kennen kein Effort → Env-Var entfernen, damit nichts Undefiniertes gesendet wird. if (!IsZaiEffortModel(modelId)) psi.Environment.Remove("CLAUDE_CODE_EFFORT_LEVEL"); // Ein evtl. global gesetzter Anthropic-API-Key darf den z.ai-Token nicht überschreiben. psi.Environment.Remove("ANTHROPIC_API_KEY"); } else { _logger.LogWarning("GLM-Modell '{Model}' gewählt, aber ZaiApiKey nicht konfiguriert – Request läuft NICHT über z.ai.", modelId); } } 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; } /// /// Reservierte MCP-Server-Namen (gott-intern) – dürfen von benutzerdefinierten Servern /// nicht überschrieben werden, sonst würde die interne Config/Allowlist kollidieren. /// private static readonly HashSet _reservedMcpNames = new HashSet(StringComparer.OrdinalIgnoreCase) { "gott-remote", "web-search-prime" }; /// /// Parst die in der Session hinterlegte MCP-Server-Liste (JSON-Array von {name,url,token}). /// Säubert die Namen (glob-/config-tauglich), filtert reservierte Namen, Duplikate und /// Einträge ohne URL. Wirft nie – ungültige Eingaben ergeben eine leere Liste. /// public static List ParseMcpServers(string json) { var result = new List(); if (string.IsNullOrWhiteSpace(json)) return result; try { using var doc = JsonDocument.Parse(json); if (doc.RootElement.ValueKind != JsonValueKind.Array) return result; var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var el in doc.RootElement.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; var rawName = el.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() : null; var url = el.TryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String ? u.GetString() : null; var token = el.TryGetProperty("token", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString() : null; var name = SanitizeMcpName(rawName); if (string.IsNullOrEmpty(name) || string.IsNullOrWhiteSpace(url)) continue; if (_reservedMcpNames.Contains(name)) continue; if (!seen.Add(name)) continue; // doppelter Name → ignorieren result.Add(new McpServerDef { Name = name, Url = url.Trim(), Token = string.IsNullOrWhiteSpace(token) ? null : token.Trim() }); } } catch { /* ungültiges JSON → leere Liste */ } return result; } /// /// Reduziert einen MCP-Server-Namen auf glob-/config-taugliche Zeichen (Buchstaben, Ziffern, /// '_' und '-'). Andere Zeichen (inkl. Leerzeichen) werden entfernt. /// private static string SanitizeMcpName(string raw) { if (string.IsNullOrWhiteSpace(raw)) return null; var sb = new StringBuilder(); foreach (var c in raw.Trim()) { if (char.IsLetterOrDigit(c) || c == '_' || c == '-') sb.Append(c); } return sb.Length > 0 ? sb.ToString() : 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 ultracode = false, bool webSearch = false, string mcpConfigPath = null, List mcpServerNames = null, string effort = null, string sessionId = null, string chainId = 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"; // Ultracode nutzt xhigh-Reasoning (das reale Äquivalent – "ultracode" selbst ist KEIN // gültiger Effort-Wert der CLI, wird still ignoriert). Ein bereits gewähltes "max" wird // nicht heruntergestuft. var effortToUse = ultracode ? (effort == "max" ? "max" : "xhigh") : effort; var args = new List { "-p", "--verbose", "--output-format", "stream-json", "--include-partial-messages", "--no-session-persistence", "--permission-mode", permissionMode }; // Tools, die das Modell gar nicht erst anbieten/versuchen soll. var disallowedTools = new List { "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"); } // Ultracode: die nativen dynamischen Workflows der Claude CLI zulassen. Ohne diese // Freigabe lehnt die Permission-Engine das Workflow-Tool headless STILL ab (kein // interaktiver Gate im -p-Modus) → der Workflow läuft nicht. Task = die Subagenten, // die ein Workflow spawnt. Nur Claude (z.ai/GLM hat kein Workflow-Runtime), nicht im // Plan-Modus (read-only). if (ultracode && !planMode && !IsZaiModel(modelId)) { tools.Add("Workflow"); tools.Add("Task"); } if (webSearch) { // GLM/z.ai kann Anthropics server-seitige WebSearch/WebFetch nicht ausführen → // stattdessen z.ais Web-Search-Prime-MCP-Tool zulassen. Der zugehörige MCP-Server // wird in GenerateMcpConfig injiziert, sobald GLM + Web-Suche + ZaiApiKey gesetzt sind. if (IsZaiModel(modelId) && !string.IsNullOrEmpty(_settings?.Get("ZaiApiKey"))) { tools.Add("mcp__web-search-prime__*"); // Die nativen Web-Tools komplett verbieten: sonst versucht das Modell zuerst // eine (über z.ai nicht ausführbare) WebSearch, scheitert headless an der // Berechtigung und greift erst im zweiten Anlauf zum Prime-MCP. Mit dem Verbot // werden sie dem Modell gar nicht angeboten → direkt das Prime-Tool. disallowedTools.Add("WebSearch"); disallowedTools.Add("WebFetch"); } else { 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"); } // Benutzerdefinierte MCP-Server: alle Tools je Server erlauben. // WICHTIG: Die CLI-Permission-Engine vergleicht den SERVER-Namen per strenger // Gleichheit (Regel wird als mcp____ geparst; ein '*' wirkt NUR im // Tool-Teil, NICHT im Server-Teil). Ein Glob wie "mcp__**" würde zu einem // Server namens "**" parsen und NIE matchen. Daher exakte Form // "mcp____*" (exakter Server + alle Tools) – dieselbe Form wie web-search-prime. // Konsequenz: Der eingetragene Name MUSS dem echten Tool-Präfix entsprechen // (z.B. "UnityMCP", nicht "Unity"). if (mcpServerNames != null) { foreach (var mcpName in mcpServerNames) { if (!string.IsNullOrWhiteSpace(mcpName)) tools.Add($"mcp__{mcpName}__*"); } } args.Add("--allowed-tools"); args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\""); args.Add("--disallowed-tools"); args.Add(string.Join(" ", disallowedTools)); 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); } // Defensiv: falls eine unaufgelöste z.ai-ID ("glm:glm-4.6") durchrutscht, Prefix strippen, // damit --model die rohe ID (z.B. "glm-4.6") erhält, die z.ai erwartet. if (!string.IsNullOrEmpty(modelId) && modelId.StartsWith("glm:", System.StringComparison.OrdinalIgnoreCase)) modelId = modelId.Substring(4); 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, effortToUse, workspace, modelId, sessionId, chainId); } private async Task RunCliStreamingAsync( List args, string stdinPrompt, Func onEvent, CancellationToken cancellationToken, string effort = null, string workspace = null, string modelId = null, string sessionId = null, string chainId = 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, modelId: modelId); 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; // Subagent-Events ausblenden: Top-Level-Messages mit gesetztem // parent_tool_use_id stammen aus einem per 'Agent'-Tool gespawnten // Subagenten. Deren Interna (tool_use kommt als type:"assistant" ohne // Handler → verworfen; tool_result als type:"user" → erzeugte sonst eine // namenlose, label-lose Box) werden konsequent unterdrückt – konsistent // zum Session-Agent. Die Tool-Box des Agent-Aufrufs selbst hat // parent_tool_use_id=null und bleibt sichtbar; der finale 'result'-Event // (Tokens, inkl. aggregierter Subagent-Tokens) trägt ebenfalls keins. if (json.TryGetProperty("parent_tool_use_id", out var parentToolUseId) && parentToolUseId.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(parentToolUseId.GetString())) { // Subagent-Event aus dem Haupt-Stream AUSBLENDEN (continue). Hinweis: Die // dynamischen Workflow-Subagenten laufen out-of-band (eigener Hintergrund- // Task + Transcript) und tauchen hier ohnehin NICHT auf – ihre Telemetrie // wird aus den Transcript-Dateien gelesen (siehe TapWorkflowLaunch). Dieser // Guard betrifft nur klassische Task-Subagenten (sichtbarer Verlauf bleibt sauber). continue; } 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 == "assistant" && json.TryGetProperty("message", out var asMsg) && asMsg.TryGetProperty("usage", out var asUsage)) { // Konsolidierter assistant-Turn des HAUPT-Streams (Subagenten sind durch den // parent_tool_use_id-Guard oben bereits ausgefiltert). message.usage liefert die // Live-Token dieses Turns – die einzige Token-Quelle VOR dem finalen result-Event. // Wir emittieren sie, damit der Aufrufer bei einem Abbruch (kein result-Event) // nicht 0 Token persistiert. Der Stream selbst bleibt unberührt. long AInt(string n) => asUsage.TryGetProperty(n, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt64() : 0L; await onEvent(new CliStreamEvent { Type = CliEventType.Usage, InputTokens = AInt("input_tokens"), OutputTokens = AInt("output_tokens"), CacheReadTokens = AInt("cache_read_input_tokens"), CacheCreationTokens = AInt("cache_creation_input_tokens") }); } 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 }); // Dynamischen Workflow-Start abgreifen (Transcript-Pfad merken), // damit der /workflow-status-Endpoint die Telemetrie aus den // Transcript-Dateien lesen kann. Stört den Stream nie. if (!resultIsError) TapWorkflowLaunch(sessionId, chainId, resultContent); } } } } } 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, Usage // Orchestrator-Usage eines assistant-Turns (Live-Akkumulation, Token-Fix bei Abbruch) } 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; } // Usage-Event: per-Turn-Usage des Haupt-Streams (kein Subagent – parent_tool_use_id ist null) public long InputTokens { get; set; } public long OutputTokens { get; set; } public long CacheReadTokens { get; set; } public long CacheCreationTokens { 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; } }