diff --git a/Services/ClaudeCliService.cs b/Services/ClaudeCliService.cs
index c553c19..0a356f0 100644
--- a/Services/ClaudeCliService.cs
+++ b/Services/ClaudeCliService.cs
@@ -2,6 +2,7 @@ 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;
@@ -35,6 +36,26 @@ public class ClaudeCliService
public bool IsAvailable => _isAvailable;
public string CliPath => _cliPath;
+ ///
+ /// Aktiviert detailliertes Encoding-Debugging für CLI-stdout.
+ /// Schreibt alle rohen Bytes vom CLI-Prozess in eine .bin-Datei und
+ /// loggt jedes Vorkommen von U+FFFD (Replacement-Char) in eine .log-Datei.
+ /// Aktivierung: Umgebungsvariable GOTT_CLI_DEBUG_BYTES=1
+ ///
+ private static bool IsByteDebugEnabled()
+ {
+ var v = Environment.GetEnvironmentVariable("GOTT_CLI_DEBUG_BYTES");
+ return !string.IsNullOrEmpty(v) && (v == "1" || string.Equals(v, "true", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static string GetDebugDir()
+ {
+ // data/cli-debug/ relativ zur Anwendung – wird automatisch erstellt
+ var dir = Path.Combine(AppContext.BaseDirectory, "data", "cli-debug");
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
private ProcessStartInfo CreateCliStartInfo(List args, bool redirectInput = false)
{
var psi = new ProcessStartInfo
@@ -219,6 +240,9 @@ public class ClaudeCliService
var lineBuffer = new StringBuilder();
+ // Encoding-Debug Cleanup-Holder (im finally geschlossen)
+ StreamWriter debugMarkerWriter = null;
+
try
{
var startInfo = CreateCliStartInfo(args, redirectInput: true);
@@ -248,10 +272,83 @@ public class ClaudeCliService
await process.StandardInput.FlushAsync();
process.StandardInput.Close();
- using var reader = process.StandardOutput;
+ // === 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;
@@ -534,10 +631,33 @@ public class ClaudeCliService
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.
///
@@ -620,6 +740,103 @@ public class ClaudeCliService
}
}
+///
+/// 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,