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 und Duplikate.
/// Einträge OHNE URL sind erlaubt (Url = null): sie erzeugen KEINEN eigenen Server,
/// steuern aber die Allowlist-Freigabe mcp__<name>__* bei – gedacht für Server, die
/// bereits extern (z.B. stdio via ~/.claude.json) laufen und die gott nur erlauben, aber
/// nicht selbst starten soll (sonst kollidiert gotts URL-Server mit dem externen gleichen
/// Namens). Nur Name+leer wird verworfen. Wirft nie – ungültige Eingaben ergeben 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)) continue; // URL optional: ohne URL = reiner Allowlist-Eintrag
if (_reservedMcpNames.Contains(name)) continue;
if (!seen.Add(name)) continue; // doppelter Name → ignorieren
result.Add(new McpServerDef { Name = name, Url = string.IsNullOrWhiteSpace(url) ? null : 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; }
}