Files
gott/Services/ClaudeCliService.cs
T
MelekiandClaude Opus 4.8 3ae94fb697 Workflow-Totalverlust beheben: inkrementelle Persistenz + Token-Fix + Resume
Bisher wurden Workflow-Subagent-Outputs nur beim sauberen FinalizeChain in
die DB gerettet - bei Abbruch (529/Timeout/Stop/Hard-Kill) war alles weg,
Tokens standen auf 0.

- A) Inkrementelle Persistenz: MaybeFlushWorkflowProgress() (throttled 6s)
  schreibt workflow_info + Token + runId waehrend des Laufs -> ueberlebt
  Hard-Kill (OOM/Redeploy), wo kein catch/finally laeuft.
- B) Output-Rettung: ReadLive liest jetzt vollstaendige Subagent-Outputs aus
  journal.jsonl (nicht nur Counts), inkl. State.
- C) Token-Fix: neuer assistant-Branch -> CliEventType.Usage; EffectiveUsage()
  rettet Orchestrator-Token bei Abbruch statt 0.
- D) Resume: Feld/Migration workflow_run_id; FinalizeChain persistiert die
  runId; naechster Chat injiziert einen resumeFromRunId-Hinweis.
- UI: Workflow-Status-Modal zeigt geretteten Output + runId/"fortsetzbar"-Banner.

Build 0/0, ReadLive gegen echtes journal.jsonl verifiziert, adversarial 2x geprueft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:14:47 +02:00

1184 lines
54 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>
/// Benutzerdefinierter MCP-Server (pro Session). URL-basiert (http/sse), optionaler Bearer-Token.
/// </summary>
public class McpServerDef
{
public string Name { get; set; }
public string Url { get; set; }
public string Token { get; set; }
}
/// <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;
private readonly ServerSettingsService _settings;
private readonly WorkflowProgressTracker _workflowTracker;
public ClaudeCliService(ILogger<ClaudeCliService> 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;
/// <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;
}
/// <summary>
/// Erkennt z.ai/GLM-Modelle. Diese laufen über den Anthropic-kompatiblen z.ai-Endpoint,
/// nicht über das normale Claude-OAuth-Login.
/// </summary>
public static bool IsZaiModel(string modelId)
=> !string.IsNullOrEmpty(modelId) && modelId.StartsWith("glm-", System.StringComparison.OrdinalIgnoreCase);
/// <summary>
/// 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".
/// </summary>
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);
}
/// <summary>
/// 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 / &lt;runId&gt;.json) gelesen, nicht aus
/// dem Stream. Rein additiv – wirft nie (Telemetrie darf den Stream-Parser nicht stören).
/// </summary>
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: <absoluter Pfad>" 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<string> 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;
}
/// <summary>
/// Reservierte MCP-Server-Namen (gott-intern) – dürfen von benutzerdefinierten Servern
/// nicht überschrieben werden, sonst würde die interne Config/Allowlist kollidieren.
/// </summary>
private static readonly HashSet<string> _reservedMcpNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"gott-remote", "web-search-prime"
};
/// <summary>
/// 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.
/// </summary>
public static List<McpServerDef> ParseMcpServers(string json)
{
var result = new List<McpServerDef>();
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<string>(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;
}
/// <summary>
/// Reduziert einen MCP-Server-Namen auf glob-/config-taugliche Zeichen (Buchstaben, Ziffern,
/// '_' und '-'). Andere Zeichen (inkl. Leerzeichen) werden entfernt.
/// </summary>
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;
}
/// <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 ultracode = false,
bool webSearch = false,
string mcpConfigPath = null,
List<string> 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<string>
{
"-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<string> { "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");
}
// 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.
// Glob mcp__*<name>* ist bewusst locker (fängt Casing-/Präfix-Varianten ab,
// z.B. Servername "unity" vs. tatsächlicher Tool-Präfix "UnityMCP").
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<CliChatResult> RunCliStreamingAsync(
List<string> args,
string stdinPrompt,
Func<CliStreamEvent, Task> 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("�", "<<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()))
{
// 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;
}
/// <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,
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; }
}