Files
gott/Services/ClaudeCliService.cs
T
MelekiandClaude Opus 4.8 17ba4840dd Hide subagent stream events in the CLI parser
When a panel member spawned a subagent via the Agent tool, the Claude CLI
emitted the subagent's internal steps as separate top-level messages
carrying parent_tool_use_id: tool_use blocks arrived as type:"assistant"
(no handler → dropped) and the matching tool_result as type:"user", which
created a nameless, label-less collapsible box in the frontend — both in
the live stream and in the persisted history (same segment source).

Skip any top-level event with a non-empty parent_tool_use_id at the single
stream JSON parser, so subagent internals never become CliStreamEvents.
This covers worker and panel-member paths at once. The Agent tool call
itself (parent_tool_use_id=null) stays visible, and the final result event
— which aggregates subagent tokens — carries no parent_tool_use_id, so the
token balance is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 17:47:41 +02:00

926 lines
40 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 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).
/// </summary>
public class ClaudeCliService
{
private readonly ILogger<ClaudeCliService> _logger;
private readonly string _cliPath;
private readonly bool _isAvailable;
public ClaudeCliService(ILogger<ClaudeCliService> 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;
/// <summary>
/// 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
/// </summary>
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<string> 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;
}
/// <summary>
/// Streaming-Chat über die Claude CLI mit Event-basiertem Callback.
/// Unterstützt Text-Streaming und Tool-Use Events.
/// </summary>
public async Task<CliChatResult> ChatStreamingAsync(
string prompt,
Func<CliStreamEvent, Task> 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<string>
{
"-p",
"--verbose",
"--output-format", "stream-json",
"--include-partial-messages",
"--no-session-persistence",
"--permission-mode", permissionMode,
"--disallowed-tools", "AskUserQuestion"
};
// Tools konfigurieren
var tools = new List<string>();
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<CliChatResult> RunCliStreamingAsync(
List<string> args,
string stdinPrompt,
Func<CliStreamEvent, Task> 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("�", "<<U+FFFD>>");
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("�", "<<U+FFFD>>")}");
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<JsonElement>(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()))
{
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 == "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;
}
/// <summary>
/// Prüft ob die CLI funktioniert und authentifiziert ist.
/// </summary>
public async Task<CliStatusResult> 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<CliChatResult> RunSimpleAsync(List<string> 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;
}
}
/// <summary>
/// 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.
/// </summary>
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<int> ReadAsync(Memory<byte> 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<int> 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<byte> 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; }
}